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>
This commit is contained in:
@@ -868,6 +868,77 @@ class MySignViewTest(TestCase):
|
|||||||
self.assertRedirects(response, reverse("billboard:my_sign"))
|
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):
|
class BillboardAppletMySignTest(TestCase):
|
||||||
"""My Sign applet rendering on /billboard/."""
|
"""My Sign applet rendering on /billboard/."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ urlpatterns = [
|
|||||||
path("buds/search", views.search_buds, name="search_buds"),
|
path("buds/search", views.search_buds, name="search_buds"),
|
||||||
path("my-sign/", views.my_sign, name="my_sign"),
|
path("my-sign/", views.my_sign, name="my_sign"),
|
||||||
path("my-sign/save", views.save_sign, name="save_sign"),
|
path("my-sign/save", views.save_sign, name="save_sign"),
|
||||||
|
path("my-sign/clear", views.clear_sign, name="clear_sign"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -306,6 +306,21 @@ def save_sign(request):
|
|||||||
return redirect("billboard:my_sign")
|
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) ────────────────────────
|
# ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
|
||||||
# Templates also live under templates/apps/billboard/. URL names sit in the
|
# Templates also live under templates/apps/billboard/. URL names sit in the
|
||||||
# `billboard:` namespace so reversers across the codebase carry the prefix.
|
# `billboard:` namespace so reversers across the codebase carry the prefix.
|
||||||
|
|||||||
@@ -451,3 +451,91 @@ class MySignBackupDeckTest(FunctionalTest):
|
|||||||
href.endswith("/gameboard/"),
|
href.endswith("/gameboard/"),
|
||||||
f"FYI should link to /gameboard/, got {href!r}",
|
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)
|
||||||
|
|||||||
@@ -668,6 +668,7 @@ html:has(.sig-backdrop) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
// SCAN SIGN btn — centered in the hex. Default .btn-primary text
|
// SCAN SIGN btn — centered in the hex. Default .btn-primary text
|
||||||
// (0.875rem) scales tighter than the room's PICK SIGS btn font; this
|
// (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;
|
line-height: 1.1;
|
||||||
white-space: normal;
|
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
|
// Hide SAVE SIGN on landing — the form only makes sense once the user
|
||||||
|
|||||||
@@ -102,6 +102,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# 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 %}
|
||||||
|
<form id="id_clear_sign_form" class="my-sign-clear-form" method="POST" action="{% url 'billboard:clear_sign' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" id="id_clear_sign_btn" class="btn btn-danger">DEL</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Picker phase — card grid, hidden until SCAN SIGN click. Each #}
|
{# Picker phase — card grid, hidden until SCAN SIGN click. Each #}
|
||||||
|
|||||||
Reference in New Issue
Block a user