From c8a603484e63164ce76cf6892e4989ffcd56a62d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 19 May 2026 14:13:20 -0400 Subject: [PATCH] =?UTF-8?q?My=20Sign=20DEL=20btn:=20clear-sign=20affordanc?= =?UTF-8?q?e=20on=20SCAN=20SIGN=20landing=20=E2=80=94=20Sprint=204b-adjace?= =?UTF-8?q?nt=20of=20My=20Sea=20roadmap=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): `
` w. ``, 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 Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../billboard/tests/integrated/test_views.py | 71 +++++++++++++++ src/apps/billboard/urls.py | 1 + src/apps/billboard/views.py | 15 ++++ src/functional_tests/test_bill_my_sign.py | 88 +++++++++++++++++++ src/static_src/scss/_card-deck.scss | 12 +++ src/templates/apps/billboard/my_sign.html | 9 ++ 6 files changed, 196 insertions(+) diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index e15d9f7..0befecb 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -868,6 +868,77 @@ class MySignViewTest(TestCase): self.assertRedirects(response, reverse("billboard:my_sign")) +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")) + + class BillboardAppletMySignTest(TestCase): """My Sign applet rendering on /billboard/.""" diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index d61e0c2..47be175 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -25,4 +25,5 @@ urlpatterns = [ path("buds/search", views.search_buds, name="search_buds"), path("my-sign/", views.my_sign, name="my_sign"), path("my-sign/save", views.save_sign, name="save_sign"), + path("my-sign/clear", views.clear_sign, name="clear_sign"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 660e7a9..59bcc46 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -306,6 +306,21 @@ def save_sign(request): return redirect("billboard:my_sign") +@login_required(login_url="/") +def clear_sign(request): + """Wipe the user's saved sig — POST `/billboard/my-sign/clear`. + Sprint 4b-adjacent. Unblocks manual verification of My Sea's no-sig + branch on dev users w. a sig already set; also gives end users a way + to undo a saved choice without re-picking. GET redirects back to the + picker (no mutation) per the existing save_sign convention.""" + if request.method != "POST": + return redirect("billboard:my_sign") + request.user.significator = None + request.user.significator_reversed = False + request.user.save(update_fields=["significator", "significator_reversed"]) + return redirect("billboard:my_sign") + + # ── Post / Line CRUD (relocated from apps.dashboard) ──────────────────────── # Templates also live under templates/apps/billboard/. URL names sit in the # `billboard:` namespace so reversers across the codebase carry the prefix. diff --git a/src/functional_tests/test_bill_my_sign.py b/src/functional_tests/test_bill_my_sign.py index 4bf8a08..e77f652 100644 --- a/src/functional_tests/test_bill_my_sign.py +++ b/src/functional_tests/test_bill_my_sign.py @@ -451,3 +451,91 @@ class MySignBackupDeckTest(FunctionalTest): href.endswith("/gameboard/"), f"FYI should link to /gameboard/, got {href!r}", ) + + +class MySignClearTest(FunctionalTest): + """Clear-sign affordance on the SCAN SIGN landing screen. + + Sprint 4b-adjacent (2026-05-19): the only way to undo a saved sig was + DB surgery — which blocked manual verification of [[sprint-my-sea-sign- + gate-may19]]'s no-sig branch on dev users w. a sig already set. Design + pre-spec'd in the 4b memory: a small CLEAR btn on the SCAN SIGN + landing screen, visible only when `user.significator` is set; POST + clears the FK + reversed flag + reloads to the no-sig landing.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_my_sign_applet() + self.email = "clear@test.io" + self.gamer = User.objects.create(email=self.email) + sig_pile = personal_sig_cards(self.gamer) + self.target_card = sig_pile[0] if sig_pile else None + self.assertIsNotNone(self.target_card) + # Pre-save a sig so the CLEAR affordance is visible on landing. + self.gamer.significator = self.target_card + self.gamer.significator_reversed = False + self.gamer.save(update_fields=["significator", "significator_reversed"]) + + # ── Test 1 ─────────────────────────────────────────────────────────────── + + def test_clear_sign_btn_renders_on_landing_when_sig_saved(self): + """When `user.significator` is set, the landing screen shows a + DEL btn alongside SCAN SIGN. Uses .btn-danger for destructive + action treatment (mirrors post.html gear menu DEL).""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_clear_sign_btn" + ) + ) + self.assertTrue(btn.is_displayed()) + self.assertIn("DEL", btn.text.upper()) + self.assertIn("btn-danger", btn.get_attribute("class")) + + # ── Test 2 ─────────────────────────────────────────────────────────────── + + def test_clear_sign_btn_absent_when_no_sig_saved(self): + """Fresh user w. no significator → no CLEAR btn on the landing.""" + # Wipe the sig set in setUp so this user lands in the no-sig state. + self.gamer.significator = None + self.gamer.save(update_fields=["significator"]) + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + # Wait for the page to settle on the SCAN SIGN btn (proxy for landing). + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_scan_sign_btn") + ) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_clear_sign_btn" + )), + 0, + ) + + # ── Test 3 ─────────────────────────────────────────────────────────────── + + def test_clear_sign_click_wipes_sig_and_reloads_to_no_sig_landing(self): + """Click CLEAR SIGN → POST → page reloads → landing shows the + no-sig state (no stage card preview, no CLEAR btn) + the DB has + `user.significator = None`.""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_clear_sign_btn") + ) + btn.click() + # After the POST → redirect → GET, the CLEAR btn should no longer + # exist + the stage card preview should be hidden. + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_clear_sign_btn" + )), + 0, + ) + ) + self.gamer.refresh_from_db() + self.assertIsNone(self.gamer.significator) + self.assertFalse(self.gamer.significator_reversed) diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index e9ab5f4..e31ca4a 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -668,6 +668,7 @@ html:has(.sig-backdrop) { flex: 1; min-height: 0; display: flex; + position: relative; // SCAN SIGN btn — centered in the hex. Default .btn-primary text // (0.875rem) scales tighter than the room's PICK SIGS btn font; this @@ -678,6 +679,17 @@ html:has(.sig-backdrop) { line-height: 1.1; white-space: normal; } + + // DEL btn — destructive secondary action, only rendered when a sig + // is saved. Anchored bottom-right of the landing area so it doesn't + // compete w. the centered SCAN SIGN hex for visual weight. .btn-danger + // for the destructive treatment (mirrors post.html gear menu DEL). + .my-sign-clear-form { + position: absolute; + bottom: 0.75rem; + right: 1rem; + margin: 0; + } } // Hide SAVE SIGN on landing — the form only makes sense once the user diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index a85b83b..9c80a63 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -102,6 +102,15 @@ + {# CLEAR SIGN — only when a sig is already saved. POST to clear_sign #} + {# wipes User.significator + significator_reversed + reloads back to #} + {# the no-sig landing. Sprint 4b-adjacent. #} + {% if current_significator %} + + {% csrf_token %} + + + {% endif %} {# Picker phase — card grid, hidden until SCAN SIGN click. Each #}