2026-04-22 04:02:14 -04:00
|
|
|
import json as _json
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
from django.test import TestCase
|
|
|
|
|
from django.urls import reverse
|
2026-04-22 04:02:14 -04:00
|
|
|
from django.utils import timezone
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
from apps.applets.models import Applet
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
from apps.drama.models import GameEvent, Note, ScrollPosition, record
|
2026-03-24 16:46:46 -04:00
|
|
|
from apps.epic.models import Room
|
|
|
|
|
from apps.lyric.models import User
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_billboard_applets():
|
|
|
|
|
for slug, name, cols, rows in [
|
2026-05-03 23:22:01 -04:00
|
|
|
("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),
|
2026-05-03 23:22:01 -04:00
|
|
|
("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),
|
2026-03-24 16:46:46 -04:00
|
|
|
]:
|
|
|
|
|
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"]]
|
2026-05-03 23:22:01 -04:00
|
|
|
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)
|
2026-05-03 23:22:01 -04:00
|
|
|
self.assertIn("most-recent-scroll", slugs)
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-24 17:19:09 -04:00
|
|
|
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))
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
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"]), [])
|
|
|
|
|
|
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')
|
|
|
|
|
|
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!'
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
class 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)
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
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"),
|
2026-05-03 23:22:01 -04:00
|
|
|
{"applets": ["my-scrolls"]},
|
2026-03-24 16:46:46 -04:00
|
|
|
)
|
|
|
|
|
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")
|
2026-03-24 16:46:46 -04:00
|
|
|
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"),
|
2026-05-03 23:22:01 -04:00
|
|
|
{"applets": ["my-scrolls"]},
|
2026-03-24 16:46:46 -04:00
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
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.
|
2026-05-03 17:15:26 -04:00
|
|
|
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": [
|
2026-05-03 23:22:01 -04:00
|
|
|
"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",
|
2026-05-03 23:22:01 -04:00
|
|
|
"most-recent-scroll",
|
2026-05-03 17:15:26 -04:00
|
|
|
]},
|
|
|
|
|
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"),
|
2026-05-03 23:22:01 -04:00
|
|
|
{"applets": ["my-scrolls"]},
|
2026-05-03 17:15:26 -04:00
|
|
|
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.
|
2026-05-03 17:15:26 -04:00
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:toggle_applets"),
|
|
|
|
|
{"applets": [
|
|
|
|
|
"new-post", "my-posts",
|
2026-05-03 23:22:01 -04:00
|
|
|
"my-scrolls",
|
|
|
|
|
"most-recent-scroll",
|
2026-05-03 17:15:26 -04:00
|
|
|
]},
|
|
|
|
|
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.
|
2026-05-03 17:15:26 -04:00
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:toggle_applets"),
|
|
|
|
|
{"applets": [
|
|
|
|
|
"new-post", "my-posts",
|
2026-05-03 23:22:01 -04:00
|
|
|
"my-scrolls",
|
2026-05-03 17:15:26 -04:00
|
|
|
]},
|
|
|
|
|
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")
|
2026-05-03 23:22:01 -04:00
|
|
|
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
2026-05-03 17:15:26 -04:00
|
|
|
self.assertFalse(
|
|
|
|
|
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(
|
2026-05-03 23:22:01 -04:00
|
|
|
UserApplet.objects.get(user=self.user, applet=most_recent_scroll).visible
|
2026-05-03 17:15:26 -04:00
|
|
|
)
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
def test_uses_scroll_template(self):
|
2026-03-24 16:46:46 -04:00
|
|
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
2026-05-03 23:22:01 -04:00
|
|
|
self.assertTemplateUsed(response, "apps/billboard/scroll.html")
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
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")
|
2026-03-24 17:44:34 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-04-02 14:51:08 -04:00
|
|
|
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"')
|
|
|
|
|
|
2026-03-24 17:44:34 -04:00
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
class NotePageViewTest(TestCase):
|
2026-04-22 04:02:14 -04:00
|
|
|
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()
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
response = self.client.get("/billboard/my-notes/")
|
2026-04-22 04:02:14 -04:00
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
|
|
|
|
def test_returns_200(self):
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
response = self.client.get("/billboard/my-notes/")
|
2026-04-22 04:02:14 -04:00
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
def test_uses_note_page_template(self):
|
|
|
|
|
response = self.client.get("/billboard/my-notes/")
|
|
|
|
|
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
|
2026-04-22 04:02:14 -04:00
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
def test_passes_notes_in_context(self):
|
|
|
|
|
recog = Note.objects.create(
|
2026-04-22 04:02:14 -04:00
|
|
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
response = self.client.get("/billboard/my-notes/")
|
|
|
|
|
self.assertIn(recog, response.context["notes"])
|
2026-04-22 04:02:14 -04:00
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
def test_excludes_other_users_notes(self):
|
2026-04-22 04:02:14 -04:00
|
|
|
other = User.objects.create(email="other@test.io")
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
Note.objects.create(
|
2026-04-22 04:02:14 -04:00
|
|
|
user=other, slug="stargazer", earned_at=timezone.now()
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
response = self.client.get("/billboard/my-notes/")
|
|
|
|
|
self.assertEqual(list(response.context["notes"]), [])
|
2026-04-22 04:02:14 -04:00
|
|
|
|
|
|
|
|
def test_renders_recog_list_and_items(self):
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
Note.objects.create(
|
2026-04-22 04:02:14 -04:00
|
|
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
response = self.client.get("/billboard/my-notes/")
|
|
|
|
|
self.assertContains(response, 'class="note-list"')
|
|
|
|
|
self.assertContains(response, 'class="note-item"')
|
2026-04-22 04:02:14 -04:00
|
|
|
|
|
|
|
|
def test_renders_recog_item_title_description_image_box(self):
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
Note.objects.create(
|
2026-04-22 04:02:14 -04:00
|
|
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
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"')
|
2026-04-22 04:02:14 -04:00
|
|
|
|
2026-04-23 01:31:19 -04:00
|
|
|
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")
|
|
|
|
|
|
2026-04-22 04:02:14 -04:00
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
class NoteSetPaletteViewTest(TestCase):
|
2026-04-22 04:02:14 -04:00
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="setpal@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
self.note = Note.objects.create(
|
2026-04-22 04:02:14 -04:00
|
|
|
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
self.url = "/billboard/note/stargazer/set-palette"
|
2026-04-22 04:02:14 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
def test_sets_palette_on_note(self):
|
2026-04-22 04:02:14 -04:00
|
|
|
self.client.post(
|
|
|
|
|
self.url,
|
|
|
|
|
data=_json.dumps({"palette": "palette-bardo"}),
|
|
|
|
|
content_type="application/json",
|
|
|
|
|
)
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
self.note.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.note.palette, "palette-bardo")
|
2026-04-22 04:02:14 -04:00
|
|
|
|
|
|
|
|
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(
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
"/billboard/note/schizo/set-palette",
|
2026-04-22 04:02:14 -04:00
|
|
|
data=_json.dumps({"palette": "palette-bardo"}),
|
|
|
|
|
content_type="application/json",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
|
|
2026-04-22 23:54:05 -04:00
|
|
|
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")
|
|
|
|
|
|
2026-04-22 04:02:14 -04:00
|
|
|
|
2026-04-23 01:44:58 -04:00
|
|
|
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)
|
2026-04-28 02:00:22 -04:00
|
|
|
data = response.json()
|
|
|
|
|
self.assertTrue(data["ok"])
|
|
|
|
|
self.assertEqual(data["greeting"], "Welcome,")
|
|
|
|
|
self.assertEqual(data["title"], "Earthman")
|
2026-04-23 01:44:58 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 17:44:34 -04:00
|
|
|
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)
|
2026-05-12 15:13:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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*<',
|
|
|
|
|
)
|
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())
|
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)
|
A.5-polish FLIP-to-back for non-polarized image-equipped decks — TDD. User-spec'd feature 2026-05-25 PM after browser-verifying A.5: the FLIP button on my_sign.html cycles polarity for polarized decks (Earthman) — gravity/levity swap w. a 3D-spin animation, stat block updates to the new polarity's emanation/reversal qualifiers. For non-polarized decks (Minchiate today, future RWS-with-images, future classic-playing decks), polarity has no meaning — clicking FLIP just runs an animation that doesn't change anything content-wise. User wants FLIP repurposed for non-polarized decks: reveal the card-back image while leaving the stat block untouched, so the gesture has visible payoff w/o forcing a meaningless polarity-state change. Implementation thread: server-side page wrapper carries a new `data-deck-polarized="{{ user.equipped_deck.is_polarized|yesno:'true,false' }}"` attr so the in-page JS can branch on it without making an API call or guessing from card data; stage-card scaffold conditionally renders a hidden `<img.sig-stage-card-back-img>` element when `equipped_deck.has_card_images AND NOT is_polarized` (image-equipped polarized decks would still cycle polarity per existing flow — back-image element absent for them, no resource waste). JS branch in `flipBtn.click`: `if (pageEl.dataset.deckPolarized === 'false') { stageCard.classList.toggle('is-flipped-to-back') } else { _flipPolarityAnimated() }` — same `.is-reversed` class toggle on the btn itself so visual feedback is consistent across both modes (btn rotates to signal "flipped state on"). SCSS: `.sig-stage-card-back-img` joins the existing `.sig-stage-card-img` filter chain (same contour stroke + silhouette black shadow — back image gets identical visual treatment to the front so the flip reads as same-deck consistency); default `display: none`; `.sig-stage-card.is-flipped-to-back` flips visibility — hides front, shows back. Stat block + arcana-key stroke color stay put per user spec — FLIP for non-polarized is purely a visual reveal, no polarity-cycle or content swap. 3 new ITs in `MySignViewTest`: data-deck-polarized="true" for default Earthman; data-deck-polarized="false" + back-img element present w. correct v2-convention back asset URL when user switches to Minchiate; polarized deck omits the back-img element. No JS unit test (Jasmine spec) for the flipBtn branch — visual verify covers the hover/click interaction; the IT covers the server-side conditional render that determines whether the branch can fire. No FT (the existing my_sign FTs cover the polarized-flip flow already; non-polarized-flip is a CSS class toggle, low-risk for regression). Tests: 3 new green; 9/9 MySignViewTest class green; 1303/1303 IT+UT total green (71s; +3 from 82813e9's 1300). Out of scope: my_sea's central sig card doesn't have a FLIP btn (no analogous behavior to add there); room.html FLIP behavior will be covered in A.8 if applicable; Sea Stage modal FLIP behavior (if any) lands in the my-sea fetch-endpoint extension later in A.5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:31:42 -04:00
|
|
|
|
|
|
|
|
def test_page_carries_data_deck_polarized_attr(self):
|
|
|
|
|
"""Sprint A.5-polish — the my_sign page wrapper exposes the equipped
|
|
|
|
|
deck's `is_polarized` state via `data-deck-polarized` so the FLIP-btn
|
|
|
|
|
JS can branch: polarized decks cycle polarity (existing behavior);
|
|
|
|
|
non-polarized decks flip to the deck card-back (new)."""
|
|
|
|
|
import lxml.html
|
|
|
|
|
# Default Earthman = is_polarized=True per A.0 migration.
|
|
|
|
|
response = self.client.get(reverse("billboard:my_sign"))
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[page] = parsed.cssselect(".my-sign-page")
|
|
|
|
|
self.assertEqual(page.get("data-deck-polarized"), "true")
|
|
|
|
|
|
|
|
|
|
def test_image_deck_renders_back_img_in_stage_scaffold(self):
|
|
|
|
|
"""Image-equipped non-polarized decks (Minchiate) render a hidden
|
|
|
|
|
<img.sig-stage-card-back-img> inside the stage card; toggled visible
|
|
|
|
|
by the FLIP-btn JS handler via the .is-flipped-to-back class."""
|
|
|
|
|
from apps.epic.models import DeckVariant
|
|
|
|
|
import lxml.html
|
|
|
|
|
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
|
|
|
|
self.user.unlocked_decks.add(minchiate)
|
|
|
|
|
self.user.equipped_deck = minchiate
|
|
|
|
|
self.user.save(update_fields=["equipped_deck"])
|
|
|
|
|
response = self.client.get(reverse("billboard:my_sign"))
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[page] = parsed.cssselect(".my-sign-page")
|
|
|
|
|
self.assertEqual(page.get("data-deck-polarized"), "false")
|
|
|
|
|
[back_img] = parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"minchiate-fiorentine-1860-1890-back.png",
|
|
|
|
|
back_img.get("src", ""),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_polarized_deck_omits_back_img(self):
|
|
|
|
|
"""Earthman (polarized) keeps the existing polarity-cycle FLIP — no
|
|
|
|
|
back-image element needed in the scaffold."""
|
|
|
|
|
import lxml.html
|
|
|
|
|
response = self.client.get(reverse("billboard:my_sign"))
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")), 0,
|
|
|
|
|
"Polarized deck must not render the back-image element",
|
|
|
|
|
)
|
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
|
|
|
self.user.refresh_from_db()
|
|
|
|
|
self.assertIsNone(self.user.significator_id)
|
|
|
|
|
|
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
|
|
|
def test_stat_block_renders_rank_suit_chip_per_face(self):
|
|
|
|
|
"""Sprint A.7.5 — `.stat-face-header` wraps the new top-left rank+suit
|
|
|
|
|
chip inline w. the EMANATION/REVERSAL label per [[project-image-based-
|
|
|
|
|
deck-face-rendering]]'s A.3 Q3 spec. Empty by default (JS-populated by
|
|
|
|
|
stage-card.js populateStatExtras on focus); both upright + reversed
|
|
|
|
|
faces carry their own chip slot so post-SPIN the chip stays visible."""
|
|
|
|
|
import lxml.html
|
|
|
|
|
response = self.client.get(reverse("billboard:my_sign"))
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
|
|
|
|
face = parsed.cssselect(f".sig-stat-block .{face_cls}")
|
|
|
|
|
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
|
|
|
|
[header] = face[0].cssselect(".stat-face-header")
|
A.7.5-polish-4 stat-block chip restructure + top-pin + CSS-transition SPIN + sea-sig-card image-mode bg fix + title --quaUser unification — TDD. Mid-session 2026-05-25 PM bundle of 5 user-spec'd polish threads atop the polish-3 alpha bump (1839a37):
(1) **Card title color unified to --quaUser** — shared `stat-block-shared` mixin's `.stat-face-title` was `--quiUser` (cream-purple) for non-major arcana, but the My Sign applet's bespoke override at `_billboard.scss:642` had it as `--quaUser` (bright yellow-gold). User-observed inconsistency 2026-05-25 PM: "only the My Sign applet has --quaUser as a font color; the rest are --quiUser. Let's change the latter to match the former". Mixin default flipped — applet's bespoke override stays (was always --quaUser, the new universal value).
(2) **Stat-face top-pin** — `.stat-face` top padding collapsed from `0.37 * card-w` (which mid-vertically centered the arcana label) to `0.1 * card-w` (uniform w. bottom) so the chip + EMANATION/REVERSAL header pin at the actual top edge + title/arcana/keywords cascade DOWN naturally. User-spec 2026-05-25 PM: "pin the number/alphanumeric at the top and the rest of the content cascades down from it, instead of pinning the arcana type in the center and stacking the rest of the content atop it".
(3) **Chip layout restructured** — header is now a 2-row vertical stack (was a 1-row flex w. chip-pill + label inline). Row 1: `.stat-chip-rank` on its OWN line (room for long Roman numerals like XXVIII without squeezing the label). Row 2: `.stat-chip-tag` flex-row holding `<i class="stat-chip-icon">` + `<p class="stat-face-label">` — the icon is always 1 char so it never crowds the label. Border-bottom on the whole `.stat-face-header` (0.05rem solid --secUser at 0.4 alpha) underscores both rows as one header unit, replacing the prior per-`.stat-face-label` `text-decoration: underline` (dropped). Per user spec 2026-05-25 PM: "allow EMANATION/REVERSAL to remain inline with the <i> el below the alphanumeric, which will more predictably only ever be one character long. Then we should extend the underline as a thin line underscoring them both (not merely underlined text)". Template-side: 4 stat-block surfaces (`my_sign.html` / `_applet-my-sign.html` / `_sea_stage.html` / `game_kit.html`) updated to the new 2-row HTML structure — `.stat-face-chip` wrapper dropped entirely; rank is a direct child of header; icon + label live in `.stat-chip-tag`. 4 ITs adjusted to match the new DOM.
(4) **SPIN animation restored for image-mode via CSS transition** — A.7.5 had gated the 180° card rotation behind `!.fan-card--image` (per the prior "monodecks shouldn't have polarity" spec), leaving image-mode cards static on SPIN while only the stat-block face toggled. User-spec 2026-05-25 PM: "reintroduce the SPIN animation". First attempt used a layered `Element.animate(0→180→0)` keyframe; user reported "card rotates back the other way even quicker". Second attempt continued past 180° to 360° for single-direction spin; user reported "now it does three! Upside down, rightside up, and upside down again!" — root cause was the layered `Element.animate` racing the existing `.fan-card { transition: transform 0.18s ease-out }` set in updateFan, producing double/triple-firing. User suggestion 2026-05-25 PM: "Why can't we just resort to the CSS transition". Final fix: drop the special-case image-mode `Element.animate` block entirely; image-mode + text-mode now share the same SPIN handler — toggle `.stage-card--reversed` + set inline `style.transform` w. the rotate(180deg) appended. The existing CSS transition handles the rotation in a single mechanism, no layering. Persistent state via `.stage-card--reversed` continues to be read by `updateFan()` so post-SPIN nav re-renders the rotation correctly.
(5) **sea-sig-card image-mode bg artifact fix** — User-reported 2026-05-25 PM: "Looks like we still have an artifact card bg behind this version of the card preview img in my_sea.html". The central sig card in `my_sea.html`'s picker was showing a beige card-shape behind the transparent-PNG art. Root cause: `.sig-stage-card.sea-sig-card` (`_card-deck.scss:1684`, specificity 0,2,0) matches the shared `.sig-stage-card.sig-stage-card--image` comma-list rule's specificity exactly but appears LATER in source order — so its `background: rgba(var(--priUser), 1)` + `border: 0.15rem solid ...` + `padding: 0.25rem` overrode the image-mode rule's `background: transparent; border: 0; padding: 0`. Fix: add a `&.sig-stage-card--image { background: transparent; border: 0; padding: 0; }` override INSIDE the bespoke rule (specificity 0,3,0 — wins both source-order against the comma-list AND beats the levity-polarity rule at line 1299). Parallel override added to `.my-sea-page[data-polarity="levity"] .sig-stage-card.sea-sig-card` (0,3,0) for the same reason — under levity the polarity rule re-clothes the sea-sig-card w. --secUser bg even in image mode; the nested `&.sig-stage-card--image` override at 0,4,0 wins. Other 3 image-mode surfaces audited: `.my-sea-slot` + `.sea-card-slot` + `.fan-card` base rules are 0,1,0 and lose to the 0,2,0 comma-list naturally; no parallel fix needed for them.
Tests: 1314/1314 IT+UT total green (73s). 4 ITs updated to match the new chip DOM structure (`.stat-face-chip` wrapper dropped; rank now direct child of header; icon + label inside `.stat-chip-tag`): BillboardMySignViewTest.test_stat_block_renders_rank_suit_chip_per_face + BillboardAppletMySignTest.test_applet_stat_block_renders_server_side_chip + MySeaViewTest.test_sea_stage_stat_block_renders_rank_suit_chip_per_face + GameKitViewTest.test_fan_stage_block_renders_rank_suit_chip_per_face. Visual verify 2026-05-25 PM via Claudezilla: chip restructure renders correctly across game_kit carousel (XXVIII Il Capricorno + Il Matto trumps); sea-sig-card bg artifact gone (computed bg `rgba(0, 0, 0, 0)`, border 0, padding 0); SPIN animation smooth in both image-mode + text-mode. No FT runs per [[feedback-ft-run-discipline]]. DRY partial split for the duplicated stat-face header markup deferred to a follow-up commit per user request 2026-05-25 PM ("hold it for a separate commit").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:14:42 -04:00
|
|
|
# Polish-4 — header is a 2-row vertical stack: rank on row 1
|
|
|
|
|
# (direct child), icon+label inside `.stat-chip-tag` on row 2.
|
|
|
|
|
[_rank] = header.cssselect(".stat-chip-rank")
|
|
|
|
|
[_tag] = header.cssselect(".stat-chip-tag")
|
|
|
|
|
[_icon] = _tag.cssselect("i.stat-chip-icon")
|
|
|
|
|
[_label] = _tag.cssselect(".stat-face-label")
|
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
|
|
|
|
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
|
|
|
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)
|
A.6 + A.7 billboard My Sign applet + gameboard My Sea applet image-rendering + applet-level FLIP-to-back — TDD. Sprints A.6 + A.7 of [[project-image-based-deck-face-rendering]]: rolls image-mode out to the two card-rendering applets (My Sign on /billboard/, My Sea on /gameboard/). Both reuse the shared `.sig-stage-card.sig-stage-card--image` SCSS contract via a comma-list selector extension covering the parallel container classes (`.my-sign-applet-card.my-sign-applet-card--image` + `.my-sea-slot.my-sea-slot--image`) — single source of truth for the contour-stroke drop-shadow chain + tray-card silhouette black depth shadow + .is-flipped-to-back visibility toggle + the `--img-stroke-color` arcana-keyed CSS prop. Templates branch server-side on `card.deck_variant.has_card_images`: image-mode renders `<img class="sig-stage-card-img" src="{{ card.image_url }}">` w. the marker class + `data-arcana-key` attr; text mode keeps the existing fan-card-corner + fan-card-face scaffold unchanged. SCSS import-order quirk: `_card-deck.scss` imports BEFORE both `_billboard.scss` (which nests `.my-sign-applet-card` inside `.my-sign-applet-body` for container queries) and `_gameboard.scss` (which nests `.my-sea-slot--filled.--gravity/--levity` inside `#id_applet_my_sea` w. specificity 1,2,0). The shared top-level image-mode rule at 0,2,0 loses on bg/border/padding to those nested base rules, so each app's stylesheet gets a parallel `&.--image { background: transparent; border: 0; padding: 0 }` override inside its own nest. The filter-chain rules on `.sig-stage-card-img` (descendant selector inside the shared rule) DO win since the apps don't restyle that class — only the outer container needs the parallel override. Sprint A.6 bonus: applet-level FLIP btn for non-polarized image-equipped decks (Minchiate today). Mirrors the my_sign.html main page A.5-polish-2 FLIP-to-back contract — `.my-sign-applet-flip-btn` nested inside the .--image card so absolute positioning anchors to the card bounds; inline `<script>` IIFE (gated inside the sig-present {% with card %} scope to keep `card` in lexical reach + prevent the JS selector string leaking into the no-sig DOM where `assertNotContains "my-sign-applet-card"` ITs catch it) attaches a click handler that runs the same rotateY 0→90→0 animation, toggles `.is-flipped-to-back` at the halfway point, and clears `data-flipping` at end; SCSS `.my-sign-applet-card[data-flipping] .my-sign-applet-flip-btn { opacity: 0; pointer-events: none }` hides the btn mid-spin. Critical scope bug caught + fixed during browser verify: initial draft had the script BLOCK + its `{% if card.deck_variant.has_card_images %}` gate placed AFTER the `{% endwith %}` closing tag — `card` was out of scope at the `{% if %}` evaluation, Django treats undefined vars as empty string, the gate evaluated falsy, and the script NEVER rendered (the FLIP btn rendered fine since it was inside the with block, but no JS handler → click did nothing but the CSS depress animation). Fix: move `{% endwith %}` to AFTER the script gate so `card` is still in scope. 7 new ITs total: 2 in `BillboardAppletMySignTest` (image-equipped Minchiate renders `--image` class + img + correct asset URL + lacks text scaffold; Earthman keeps the text scaffold + lacks `--image`); 3 in `BillboardMySignViewTest` (data-deck-polarized attr present; back-img element renders for non-polarized image deck; polarized deck omits it); 1 in `GameboardViewTest` (image-equipped Minchiate slot renders `--image` + img + lacks text scaffold); plus regression coverage on the no-sig empty-state assertion that originally caught the script-scope bug (assertNotContains validates the script doesn't leak in the no-sig case). Tests: 6 new ITs green; 1306/1306 IT+UT total green (72s; +6 from bdf6a25's 1303 — minus 3 dups since some ITs were counted across both A.6 + A.5-polish-2 runs). Visual verify by user 2026-05-25 PM: stage card image renders cleanly; FLIP cycles to back image + back via animation; FLIP btn hides during 500ms spin; placeholder dim styling correctly distinguishes no-deck state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:36 -04:00
|
|
|
|
|
|
|
|
def test_my_sign_applet_renders_image_when_deck_has_card_images(self):
|
|
|
|
|
"""Sprint A.6 — applet card carries `.my-sign-applet-card--image` +
|
|
|
|
|
an <img.sig-stage-card-img> child when the user's equipped deck is
|
|
|
|
|
image-equipped (Minchiate today). Shares the contour-stroke + depth
|
|
|
|
|
shadow SCSS w. my_sign.html's stage-card-image via comma-list selector.
|
|
|
|
|
Text scaffold (fan-card-corner / fan-card-face) is NOT rendered in
|
|
|
|
|
image mode — server-side template `{% if/else %}` branch."""
|
|
|
|
|
from apps.epic.models import DeckVariant, TarotCard
|
|
|
|
|
import lxml.html
|
|
|
|
|
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
|
|
|
|
self.user.is_superuser = True
|
|
|
|
|
self.user.save()
|
|
|
|
|
from apps.drama.models import Note
|
|
|
|
|
Note.grant_if_new(self.user, "super-nomad")
|
|
|
|
|
Note.grant_if_new(self.user, "super-schizo")
|
|
|
|
|
self.user.unlocked_decks.add(minchiate)
|
|
|
|
|
self.user.equipped_deck = minchiate
|
|
|
|
|
il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto")
|
|
|
|
|
self.user.significator = il_matto
|
|
|
|
|
self.user.save(update_fields=["equipped_deck", "significator"])
|
|
|
|
|
|
|
|
|
|
response = self.client.get("/billboard/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[card_el] = parsed.cssselect(".my-sign-applet-card")
|
|
|
|
|
self.assertIn("my-sign-applet-card--image", card_el.get("class", ""))
|
|
|
|
|
self.assertEqual(card_el.get("data-arcana-key"), "MAJOR")
|
|
|
|
|
[img] = card_el.cssselect("img.sig-stage-card-img")
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
|
|
|
|
|
img.get("src", ""),
|
|
|
|
|
)
|
|
|
|
|
# Text scaffold absent in image mode (the server-side {% if %} branch
|
|
|
|
|
# skips the fan-card-corner + fan-card-face children entirely).
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(card_el.cssselect(".fan-card-corner")), 0,
|
|
|
|
|
"Text scaffold must not render in image mode",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_my_sign_applet_keeps_text_render_for_non_image_deck(self):
|
|
|
|
|
"""Earthman (has_card_images=False) keeps the existing fan-card-corner
|
|
|
|
|
text scaffold + lacks the --image modifier class."""
|
|
|
|
|
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"])
|
|
|
|
|
import lxml.html
|
|
|
|
|
response = self.client.get("/billboard/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[card_el] = parsed.cssselect(".my-sign-applet-card")
|
|
|
|
|
self.assertNotIn("my-sign-applet-card--image", card_el.get("class", ""))
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(card_el.cssselect("img.sig-stage-card-img")), 0,
|
|
|
|
|
"Non-image deck must not render the <img>",
|
|
|
|
|
)
|
|
|
|
|
self.assertGreater(
|
|
|
|
|
len(card_el.cssselect(".fan-card-corner")), 0,
|
|
|
|
|
"Non-image deck keeps the text scaffold",
|
|
|
|
|
)
|
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
|
|
|
|
|
|
|
|
def test_applet_stat_block_renders_server_side_chip(self):
|
|
|
|
|
"""Sprint A.7.5 — applet is read-only so the rank+suit chip is server-
|
|
|
|
|
rendered (not JS-populated as on stage / sea_stage / fan stage). Chip
|
|
|
|
|
carries the card's corner_rank + suit_icon FA class inline w. the
|
|
|
|
|
EMANATION label inside `.stat-face-header`."""
|
|
|
|
|
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"])
|
|
|
|
|
import lxml.html
|
|
|
|
|
response = self.client.get("/billboard/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[block] = parsed.cssselect(".my-sign-applet-stat-block")
|
|
|
|
|
[header] = block.cssselect(".stat-face-header")
|
A.7.5-polish-4 stat-block chip restructure + top-pin + CSS-transition SPIN + sea-sig-card image-mode bg fix + title --quaUser unification — TDD. Mid-session 2026-05-25 PM bundle of 5 user-spec'd polish threads atop the polish-3 alpha bump (1839a37):
(1) **Card title color unified to --quaUser** — shared `stat-block-shared` mixin's `.stat-face-title` was `--quiUser` (cream-purple) for non-major arcana, but the My Sign applet's bespoke override at `_billboard.scss:642` had it as `--quaUser` (bright yellow-gold). User-observed inconsistency 2026-05-25 PM: "only the My Sign applet has --quaUser as a font color; the rest are --quiUser. Let's change the latter to match the former". Mixin default flipped — applet's bespoke override stays (was always --quaUser, the new universal value).
(2) **Stat-face top-pin** — `.stat-face` top padding collapsed from `0.37 * card-w` (which mid-vertically centered the arcana label) to `0.1 * card-w` (uniform w. bottom) so the chip + EMANATION/REVERSAL header pin at the actual top edge + title/arcana/keywords cascade DOWN naturally. User-spec 2026-05-25 PM: "pin the number/alphanumeric at the top and the rest of the content cascades down from it, instead of pinning the arcana type in the center and stacking the rest of the content atop it".
(3) **Chip layout restructured** — header is now a 2-row vertical stack (was a 1-row flex w. chip-pill + label inline). Row 1: `.stat-chip-rank` on its OWN line (room for long Roman numerals like XXVIII without squeezing the label). Row 2: `.stat-chip-tag` flex-row holding `<i class="stat-chip-icon">` + `<p class="stat-face-label">` — the icon is always 1 char so it never crowds the label. Border-bottom on the whole `.stat-face-header` (0.05rem solid --secUser at 0.4 alpha) underscores both rows as one header unit, replacing the prior per-`.stat-face-label` `text-decoration: underline` (dropped). Per user spec 2026-05-25 PM: "allow EMANATION/REVERSAL to remain inline with the <i> el below the alphanumeric, which will more predictably only ever be one character long. Then we should extend the underline as a thin line underscoring them both (not merely underlined text)". Template-side: 4 stat-block surfaces (`my_sign.html` / `_applet-my-sign.html` / `_sea_stage.html` / `game_kit.html`) updated to the new 2-row HTML structure — `.stat-face-chip` wrapper dropped entirely; rank is a direct child of header; icon + label live in `.stat-chip-tag`. 4 ITs adjusted to match the new DOM.
(4) **SPIN animation restored for image-mode via CSS transition** — A.7.5 had gated the 180° card rotation behind `!.fan-card--image` (per the prior "monodecks shouldn't have polarity" spec), leaving image-mode cards static on SPIN while only the stat-block face toggled. User-spec 2026-05-25 PM: "reintroduce the SPIN animation". First attempt used a layered `Element.animate(0→180→0)` keyframe; user reported "card rotates back the other way even quicker". Second attempt continued past 180° to 360° for single-direction spin; user reported "now it does three! Upside down, rightside up, and upside down again!" — root cause was the layered `Element.animate` racing the existing `.fan-card { transition: transform 0.18s ease-out }` set in updateFan, producing double/triple-firing. User suggestion 2026-05-25 PM: "Why can't we just resort to the CSS transition". Final fix: drop the special-case image-mode `Element.animate` block entirely; image-mode + text-mode now share the same SPIN handler — toggle `.stage-card--reversed` + set inline `style.transform` w. the rotate(180deg) appended. The existing CSS transition handles the rotation in a single mechanism, no layering. Persistent state via `.stage-card--reversed` continues to be read by `updateFan()` so post-SPIN nav re-renders the rotation correctly.
(5) **sea-sig-card image-mode bg artifact fix** — User-reported 2026-05-25 PM: "Looks like we still have an artifact card bg behind this version of the card preview img in my_sea.html". The central sig card in `my_sea.html`'s picker was showing a beige card-shape behind the transparent-PNG art. Root cause: `.sig-stage-card.sea-sig-card` (`_card-deck.scss:1684`, specificity 0,2,0) matches the shared `.sig-stage-card.sig-stage-card--image` comma-list rule's specificity exactly but appears LATER in source order — so its `background: rgba(var(--priUser), 1)` + `border: 0.15rem solid ...` + `padding: 0.25rem` overrode the image-mode rule's `background: transparent; border: 0; padding: 0`. Fix: add a `&.sig-stage-card--image { background: transparent; border: 0; padding: 0; }` override INSIDE the bespoke rule (specificity 0,3,0 — wins both source-order against the comma-list AND beats the levity-polarity rule at line 1299). Parallel override added to `.my-sea-page[data-polarity="levity"] .sig-stage-card.sea-sig-card` (0,3,0) for the same reason — under levity the polarity rule re-clothes the sea-sig-card w. --secUser bg even in image mode; the nested `&.sig-stage-card--image` override at 0,4,0 wins. Other 3 image-mode surfaces audited: `.my-sea-slot` + `.sea-card-slot` + `.fan-card` base rules are 0,1,0 and lose to the 0,2,0 comma-list naturally; no parallel fix needed for them.
Tests: 1314/1314 IT+UT total green (73s). 4 ITs updated to match the new chip DOM structure (`.stat-face-chip` wrapper dropped; rank now direct child of header; icon + label inside `.stat-chip-tag`): BillboardMySignViewTest.test_stat_block_renders_rank_suit_chip_per_face + BillboardAppletMySignTest.test_applet_stat_block_renders_server_side_chip + MySeaViewTest.test_sea_stage_stat_block_renders_rank_suit_chip_per_face + GameKitViewTest.test_fan_stage_block_renders_rank_suit_chip_per_face. Visual verify 2026-05-25 PM via Claudezilla: chip restructure renders correctly across game_kit carousel (XXVIII Il Capricorno + Il Matto trumps); sea-sig-card bg artifact gone (computed bg `rgba(0, 0, 0, 0)`, border 0, padding 0); SPIN animation smooth in both image-mode + text-mode. No FT runs per [[feedback-ft-run-discipline]]. DRY partial split for the duplicated stat-face header markup deferred to a follow-up commit per user request 2026-05-25 PM ("hold it for a separate commit").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:14:42 -04:00
|
|
|
# Polish-4 — rank is a direct child of header (own row); icon lives
|
|
|
|
|
# inside `.stat-chip-tag` (row-2 inline w. the EMANATION label).
|
|
|
|
|
[rank] = header.cssselect(".stat-chip-rank")
|
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
|
|
|
# Court middle cards have single-letter corner ranks (M/J/Q/K) per
|
|
|
|
|
# TarotCard.corner_rank — pin presence, not the exact value (which
|
|
|
|
|
# depends on which middle court personal_sig_cards returns first).
|
|
|
|
|
self.assertTrue(rank.text and rank.text.strip())
|
A.7.5-polish-4 stat-block chip restructure + top-pin + CSS-transition SPIN + sea-sig-card image-mode bg fix + title --quaUser unification — TDD. Mid-session 2026-05-25 PM bundle of 5 user-spec'd polish threads atop the polish-3 alpha bump (1839a37):
(1) **Card title color unified to --quaUser** — shared `stat-block-shared` mixin's `.stat-face-title` was `--quiUser` (cream-purple) for non-major arcana, but the My Sign applet's bespoke override at `_billboard.scss:642` had it as `--quaUser` (bright yellow-gold). User-observed inconsistency 2026-05-25 PM: "only the My Sign applet has --quaUser as a font color; the rest are --quiUser. Let's change the latter to match the former". Mixin default flipped — applet's bespoke override stays (was always --quaUser, the new universal value).
(2) **Stat-face top-pin** — `.stat-face` top padding collapsed from `0.37 * card-w` (which mid-vertically centered the arcana label) to `0.1 * card-w` (uniform w. bottom) so the chip + EMANATION/REVERSAL header pin at the actual top edge + title/arcana/keywords cascade DOWN naturally. User-spec 2026-05-25 PM: "pin the number/alphanumeric at the top and the rest of the content cascades down from it, instead of pinning the arcana type in the center and stacking the rest of the content atop it".
(3) **Chip layout restructured** — header is now a 2-row vertical stack (was a 1-row flex w. chip-pill + label inline). Row 1: `.stat-chip-rank` on its OWN line (room for long Roman numerals like XXVIII without squeezing the label). Row 2: `.stat-chip-tag` flex-row holding `<i class="stat-chip-icon">` + `<p class="stat-face-label">` — the icon is always 1 char so it never crowds the label. Border-bottom on the whole `.stat-face-header` (0.05rem solid --secUser at 0.4 alpha) underscores both rows as one header unit, replacing the prior per-`.stat-face-label` `text-decoration: underline` (dropped). Per user spec 2026-05-25 PM: "allow EMANATION/REVERSAL to remain inline with the <i> el below the alphanumeric, which will more predictably only ever be one character long. Then we should extend the underline as a thin line underscoring them both (not merely underlined text)". Template-side: 4 stat-block surfaces (`my_sign.html` / `_applet-my-sign.html` / `_sea_stage.html` / `game_kit.html`) updated to the new 2-row HTML structure — `.stat-face-chip` wrapper dropped entirely; rank is a direct child of header; icon + label live in `.stat-chip-tag`. 4 ITs adjusted to match the new DOM.
(4) **SPIN animation restored for image-mode via CSS transition** — A.7.5 had gated the 180° card rotation behind `!.fan-card--image` (per the prior "monodecks shouldn't have polarity" spec), leaving image-mode cards static on SPIN while only the stat-block face toggled. User-spec 2026-05-25 PM: "reintroduce the SPIN animation". First attempt used a layered `Element.animate(0→180→0)` keyframe; user reported "card rotates back the other way even quicker". Second attempt continued past 180° to 360° for single-direction spin; user reported "now it does three! Upside down, rightside up, and upside down again!" — root cause was the layered `Element.animate` racing the existing `.fan-card { transition: transform 0.18s ease-out }` set in updateFan, producing double/triple-firing. User suggestion 2026-05-25 PM: "Why can't we just resort to the CSS transition". Final fix: drop the special-case image-mode `Element.animate` block entirely; image-mode + text-mode now share the same SPIN handler — toggle `.stage-card--reversed` + set inline `style.transform` w. the rotate(180deg) appended. The existing CSS transition handles the rotation in a single mechanism, no layering. Persistent state via `.stage-card--reversed` continues to be read by `updateFan()` so post-SPIN nav re-renders the rotation correctly.
(5) **sea-sig-card image-mode bg artifact fix** — User-reported 2026-05-25 PM: "Looks like we still have an artifact card bg behind this version of the card preview img in my_sea.html". The central sig card in `my_sea.html`'s picker was showing a beige card-shape behind the transparent-PNG art. Root cause: `.sig-stage-card.sea-sig-card` (`_card-deck.scss:1684`, specificity 0,2,0) matches the shared `.sig-stage-card.sig-stage-card--image` comma-list rule's specificity exactly but appears LATER in source order — so its `background: rgba(var(--priUser), 1)` + `border: 0.15rem solid ...` + `padding: 0.25rem` overrode the image-mode rule's `background: transparent; border: 0; padding: 0`. Fix: add a `&.sig-stage-card--image { background: transparent; border: 0; padding: 0; }` override INSIDE the bespoke rule (specificity 0,3,0 — wins both source-order against the comma-list AND beats the levity-polarity rule at line 1299). Parallel override added to `.my-sea-page[data-polarity="levity"] .sig-stage-card.sea-sig-card` (0,3,0) for the same reason — under levity the polarity rule re-clothes the sea-sig-card w. --secUser bg even in image mode; the nested `&.sig-stage-card--image` override at 0,4,0 wins. Other 3 image-mode surfaces audited: `.my-sea-slot` + `.sea-card-slot` + `.fan-card` base rules are 0,1,0 and lose to the 0,2,0 comma-list naturally; no parallel fix needed for them.
Tests: 1314/1314 IT+UT total green (73s). 4 ITs updated to match the new chip DOM structure (`.stat-face-chip` wrapper dropped; rank now direct child of header; icon + label inside `.stat-chip-tag`): BillboardMySignViewTest.test_stat_block_renders_rank_suit_chip_per_face + BillboardAppletMySignTest.test_applet_stat_block_renders_server_side_chip + MySeaViewTest.test_sea_stage_stat_block_renders_rank_suit_chip_per_face + GameKitViewTest.test_fan_stage_block_renders_rank_suit_chip_per_face. Visual verify 2026-05-25 PM via Claudezilla: chip restructure renders correctly across game_kit carousel (XXVIII Il Capricorno + Il Matto trumps); sea-sig-card bg artifact gone (computed bg `rgba(0, 0, 0, 0)`, border 0, padding 0); SPIN animation smooth in both image-mode + text-mode. No FT runs per [[feedback-ft-run-discipline]]. DRY partial split for the duplicated stat-face header markup deferred to a follow-up commit per user request 2026-05-25 PM ("hold it for a separate commit").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:14:42 -04:00
|
|
|
[tag] = header.cssselect(".stat-chip-tag")
|
|
|
|
|
[icon] = tag.cssselect("i.stat-chip-icon")
|
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
|
|
|
# Middle court has a suit, so the suit-icon `<i>` is present + carries
|
|
|
|
|
# the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc).
|
|
|
|
|
self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split()))
|
bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.
Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).
URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).
Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.
mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.
Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.
JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.
SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).
Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.
Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.
1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:45:20 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Per-bud Landing Page ─────────────────────────────────────────────────
|
|
|
|
|
# /billboard/buds/<uuid:bud_id>/ + the my_buds row enrichment that surfaces
|
|
|
|
|
# the new tooltip-portal data — bud landing page sprint 2026-05-27 (see
|
|
|
|
|
# [[project-bud-landing-page-sprint]]). Replaces the @mailman invite Line's
|
|
|
|
|
# inline OK/BYE block w. a dedicated page; the My Buds list rows now wrap
|
|
|
|
|
# the `@<handle>` in an anchor to the bud's page + carry data-tt-* attrs
|
|
|
|
|
# the JS portal reads on row-lock click.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BudPageRenderTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@buds.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@buds.io", username="alice")
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
|
|
|
|
def test_returns_200(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
def test_uses_bud_template(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertTemplateUsed(response, "apps/billboard/bud.html")
|
|
|
|
|
|
|
|
|
|
def test_passes_bud_in_context(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.context["bud"], self.alice)
|
|
|
|
|
|
|
|
|
|
def test_passes_empty_shoptalk_when_no_note(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.context["shoptalk_text"], "")
|
|
|
|
|
self.assertIsNone(response.context["milestone_dt"])
|
|
|
|
|
|
|
|
|
|
def test_header_renders_at_handle_the_title_and_email(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
self.assertIn("@alice", body)
|
|
|
|
|
self.assertIn("the Earthman", body)
|
|
|
|
|
self.assertIn("alice@buds.io", body)
|
|
|
|
|
|
|
|
|
|
def test_shoptalk_textarea_carries_160_char_maxlength(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
self.assertRegex(
|
|
|
|
|
body, r'<textarea[^>]+id="id_shoptalk"[^>]*maxlength="160"',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_existing_shoptalk_renders_in_textarea(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
BudshipNote.objects.create(
|
|
|
|
|
user=self.user, bud=self.alice, shoptalk="loves chess",
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.context["shoptalk_text"], "loves chess")
|
|
|
|
|
self.assertIsNotNone(response.context["milestone_dt"])
|
|
|
|
|
self.assertContains(response, "loves chess")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BudPageAutoAddOnFirstVisitTest(TestCase):
|
|
|
|
|
"""Visiting bud.html for a non-bud auto-adds them to the user's buds —
|
|
|
|
|
mirrors share_post's implicit-add posture so the @mailman post-
|
|
|
|
|
attribution anchor lands the inviter on the user's buds graph."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@auto.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@auto.io", username="alice")
|
|
|
|
|
# alice is NOT in user.buds — auto-add is the contract
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_visit_adds_bud_to_m2m(self):
|
|
|
|
|
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
|
|
|
|
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
|
|
|
|
self.assertIn(self.alice, list(self.user.buds.all()))
|
|
|
|
|
|
|
|
|
|
def test_self_visit_does_not_self_add(self):
|
|
|
|
|
# Pathological case: navigating to your own bud page must not seed
|
|
|
|
|
# the user as their own bud (M2M is asymmetric self-FK).
|
|
|
|
|
self.client.get(reverse("billboard:bud_page", args=[self.user.id]))
|
|
|
|
|
self.assertNotIn(self.user, list(self.user.buds.all()))
|
|
|
|
|
|
|
|
|
|
def test_already_bud_visit_is_idempotent(self):
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
|
|
|
|
# M2M dedup'd; still one row
|
|
|
|
|
self.assertEqual(self.user.buds.filter(pk=self.alice.pk).count(), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BudPagePendingInviteCascadeTest(TestCase):
|
|
|
|
|
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired
|
|
|
|
|
PENDING SeaInvite exists from this bud (owner) to the viewer (invitee).
|
|
|
|
|
Reuses the same template flags `_burger.html` already reads on my_sea
|
|
|
|
|
+ room — no new template plumbing on bud.html."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
from apps.gameboard.models import SeaInvite
|
|
|
|
|
self.SeaInvite = SeaInvite
|
|
|
|
|
self.user = User.objects.create(email="me@inv.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@inv.io", username="alice")
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_no_invite_no_cascade(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(response.context["pending_invite"])
|
|
|
|
|
self.assertFalse(response.context["sea_btn_active"])
|
|
|
|
|
self.assertFalse(response.context["sea_first_draw_pending"])
|
|
|
|
|
|
|
|
|
|
def test_pending_invite_lights_cascade(self):
|
|
|
|
|
self.SeaInvite.objects.create(
|
|
|
|
|
owner=self.alice,
|
|
|
|
|
invitee=self.user,
|
|
|
|
|
invitee_email=self.user.email,
|
|
|
|
|
status=self.SeaInvite.PENDING,
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNotNone(response.context["pending_invite"])
|
|
|
|
|
self.assertTrue(response.context["sea_btn_active"])
|
|
|
|
|
self.assertTrue(response.context["sea_first_draw_pending"])
|
|
|
|
|
|
|
|
|
|
def test_accepted_invite_does_not_cascade(self):
|
|
|
|
|
self.SeaInvite.objects.create(
|
|
|
|
|
owner=self.alice,
|
|
|
|
|
invitee=self.user,
|
|
|
|
|
invitee_email=self.user.email,
|
|
|
|
|
status=self.SeaInvite.ACCEPTED,
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(response.context["pending_invite"])
|
|
|
|
|
self.assertFalse(response.context["sea_btn_active"])
|
|
|
|
|
|
|
|
|
|
def test_expired_pending_invite_does_not_cascade(self):
|
|
|
|
|
inv = self.SeaInvite.objects.create(
|
|
|
|
|
owner=self.alice,
|
|
|
|
|
invitee=self.user,
|
|
|
|
|
invitee_email=self.user.email,
|
|
|
|
|
status=self.SeaInvite.PENDING,
|
|
|
|
|
)
|
|
|
|
|
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
|
|
|
|
created_at=timezone.now() - timezone.timedelta(hours=48),
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(response.context["pending_invite"])
|
|
|
|
|
self.assertFalse(response.context["sea_btn_active"])
|
|
|
|
|
|
|
|
|
|
def test_invite_for_other_invitee_ignored(self):
|
|
|
|
|
# Pending invite from alice → some other user is irrelevant to ME.
|
|
|
|
|
other = User.objects.create(email="other@inv.io", username="other")
|
|
|
|
|
self.SeaInvite.objects.create(
|
|
|
|
|
owner=self.alice,
|
|
|
|
|
invitee=other,
|
|
|
|
|
invitee_email=other.email,
|
|
|
|
|
status=self.SeaInvite.PENDING,
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(response.context["pending_invite"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SaveBudShoptalkViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@sav.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@sav.io", username="alice")
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_post_creates_budship_note(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
|
|
|
|
{"shoptalk": "first thoughts"},
|
|
|
|
|
)
|
|
|
|
|
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
|
|
|
|
self.assertEqual(bn.shoptalk, "first thoughts")
|
|
|
|
|
|
|
|
|
|
def test_post_updates_existing_budship_note(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
BudshipNote.objects.create(user=self.user, bud=self.alice, shoptalk="old")
|
|
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
|
|
|
|
{"shoptalk": "new"},
|
|
|
|
|
)
|
|
|
|
|
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
|
|
|
|
self.assertEqual(bn.shoptalk, "new")
|
|
|
|
|
|
|
|
|
|
def test_post_caps_at_160_chars(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
|
|
|
|
{"shoptalk": "a" * 300},
|
|
|
|
|
)
|
|
|
|
|
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
|
|
|
|
self.assertLessEqual(len(bn.shoptalk), 160)
|
|
|
|
|
|
|
|
|
|
def test_get_returns_405(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("billboard:save_bud_shoptalk", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
|
|
|
def test_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
|
|
|
|
{"shoptalk": "anon"},
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeleteBudViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@del.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@del.io", username="alice")
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_post_removes_bud_from_m2m(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
reverse("billboard:delete_bud", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
|
|
|
|
|
|
|
|
|
def test_post_redirects_to_my_buds(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
reverse("billboard:delete_bud", args=[self.alice.id])
|
|
|
|
|
)
|
|
|
|
|
self.assertRedirects(response, reverse("billboard:my_buds"))
|
|
|
|
|
|
|
|
|
|
def test_get_does_not_remove(self):
|
|
|
|
|
self.client.get(reverse("billboard:delete_bud", args=[self.alice.id]))
|
|
|
|
|
self.assertIn(self.alice, list(self.user.buds.all()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MyBudsRowEnrichmentTest(TestCase):
|
|
|
|
|
"""The my_buds page row now carries the data-tt-* attrs the tooltip
|
|
|
|
|
portal reads on row-lock click, plus an anchor wrapping the handle
|
|
|
|
|
that routes to the bud's landing page."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@row.io", username="me")
|
|
|
|
|
self.alice = User.objects.create(email="alice@row.io", username="alice")
|
|
|
|
|
self.user.buds.add(self.alice)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_row_carries_data_bud_id(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
self.assertContains(response, f'data-bud-id="{self.alice.id}"')
|
|
|
|
|
|
|
|
|
|
def test_row_carries_tt_title_description_email_attrs(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
self.assertContains(response, 'data-tt-title="@alice"')
|
|
|
|
|
self.assertContains(response, 'data-tt-description="Earthman"')
|
|
|
|
|
self.assertContains(response, 'data-tt-email="alice@row.io"')
|
|
|
|
|
|
|
|
|
|
def test_row_renders_at_handle_the_title(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
self.assertIn("@alice", body)
|
|
|
|
|
self.assertIn("the Earthman", body)
|
|
|
|
|
|
|
|
|
|
def test_username_wrapped_in_anchor_to_bud_page(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
bud_page_url = reverse("billboard:bud_page", args=[self.alice.id])
|
|
|
|
|
self.assertRegex(
|
|
|
|
|
body,
|
|
|
|
|
rf'<span class="bud-name"><a[^>]*href="{bud_page_url}"',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_row_carries_shoptalk_when_set(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
BudshipNote.objects.create(
|
|
|
|
|
user=self.user, bud=self.alice, shoptalk="dragonkin",
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
self.assertContains(response, 'data-tt-shoptalk="dragonkin"')
|
|
|
|
|
self.assertContains(response, "data-tt-milestone=")
|
|
|
|
|
|
|
|
|
|
def test_row_carries_empty_shoptalk_attr_when_never_edited(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
self.assertContains(response, 'data-tt-shoptalk=""')
|
|
|
|
|
|
|
|
|
|
def test_row_omits_milestone_when_no_note(self):
|
|
|
|
|
response = self.client.get(reverse("billboard:my_buds"))
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
self.assertNotIn("data-tt-milestone=", body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BudshipNoteModelTest(TestCase):
|
|
|
|
|
"""`BudshipNote(user, bud, shoptalk, edited_at)` — per-relation note."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="me@m.io", username="me")
|
|
|
|
|
self.bud = User.objects.create(email="b@m.io", username="b")
|
|
|
|
|
|
|
|
|
|
def test_unique_per_user_bud_pair(self):
|
|
|
|
|
from django.db import IntegrityError
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="x")
|
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
|
|
|
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="y")
|
|
|
|
|
|
|
|
|
|
def test_edited_at_updates_on_save(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
bn = BudshipNote.objects.create(
|
|
|
|
|
user=self.user, bud=self.bud, shoptalk="first",
|
|
|
|
|
)
|
|
|
|
|
first_ts = bn.edited_at
|
|
|
|
|
bn.shoptalk = "second"
|
|
|
|
|
bn.save()
|
|
|
|
|
self.assertGreaterEqual(bn.edited_at, first_ts)
|
|
|
|
|
|
|
|
|
|
def test_shoptalk_max_length_160(self):
|
|
|
|
|
from apps.billboard.models import BudshipNote
|
|
|
|
|
f = BudshipNote._meta.get_field("shoptalk")
|
|
|
|
|
self.assertEqual(f.max_length, 160)
|