"""FT spec for the Bud btn sprint — post.html bottom-left handshake button. Written red BEFORE implementation as a TDD handoff so the post-compaction agent (or future Disco) can land the feature without losing intent. Run: python src/manage.py test functional_tests.test_bud_btn All tests should be RED initially. Implementation lands when they go green. ──────────────────────────────────────────────────────────────────────────── SPEC SUMMARY ──────────────────────────────────────────────────────────────────────────── • A new #id_bud_btn () sits bottom-left of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same fixed/circular/secUser-bordered look + .active-state styling. • Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html, included only by post.html (NOT the global base.html — bud is post-only). • Replaces the inline share form on post.html: typing the recipient now happens in a slide-out under the bud btn. • Click #id_bud_btn → recipient field grows L→R under it, spanning `100vw - 3rem` (1.5rem padding each side). The field is vertically centred on the bud btn's centre, w. healthy left padding so the typed text + placeholder don't overlap the btn glyph. • An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the field (replaces the legacy big SHARE .btn-primary). • While the recipient field is open: html.bud-open is set; #id_kit_btn quickly eases to opacity 0. Symmetric: when html.kit-open is set, #id_bud_btn eases to opacity 0. • Click OK → POST share-post async (existing C3.b endpoint), clears the field, closes the slide-out, slide-down Brief banner appears. • Click outside the field/btn → closes the slide-out, clears the field (no Brief). Reopening shows the placeholder, not the prior typed value. • Escape key closes the slide-out (same as kit btn pattern). • post.html + my_posts.html should be aperture-styled (body.page-billboard or new body.page-post if needed; the user noted post.html isn't currently in the base/applet scss aperture group). ──────────────────────────────────────────────────────────────────────────── IMPLEMENTATION CHECKLIST (post-compaction) ──────────────────────────────────────────────────────────────────────────── 1. Create templates/apps/billboard/_partials/_bud_panel.html w. the btn + slide-out form + inline JS. 2. Edit templates/apps/billboard/post.html: - Drop the inline `
` + #id_recipient + SHARE btn block (the share JS moves into _bud_panel.html). - Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`. 3. Edit billboard.views.view_post (or my_posts) context: - "page_class": "page-billboard" (or new page-post) so the body class picks up the aperture SCSS. 4. SCSS — add to _game-kit.scss neighbour or new _bud.scss: - #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn (3rem circle, secUser border, .active state, etc.) - #id_bud_panel (the slide-out wrapper): position fixed, left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when kit btn visible — but mutual-exclusion makes that moot), bottom- aligned w. the btn centre, transition transform/width L→R. - html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; } - html.kit-open #id_bud_btn { opacity: 0; transition: opacity 0.15s; } - The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on the trailing edge of the slide-out. 5. JS in _bud_panel.html: - Mirror game-kit.js click/escape/click-outside pattern. - Toggle html.bud-open + #id_bud_btn.active. - On submit/OK: fetch POST share-post w. Accept:application/json, reuse the C3.b response handling (line append, banner via Brief.showBanner, recipient_display chip append). - On dismiss-without-OK: clear input.value. 6. post.html + my_posts.html: add the body class hook so the aperture SCSS engages (probably page-billboard already, just need to confirm). 7. Update functional_tests.post_page.PostPage.share_post_with() to drive the bud-btn flow (click btn → type → click OK → wait for chip). 8. Re-run test_sharing.SharingTest — should still pass once the page- object mirrors the new flow. ──────────────────────────────────────────────────────────────────────────── KNOWN AT TIME OF WRITING ──────────────────────────────────────────────────────────────────────────── - #id_kit_btn lives in templates/core/base.html (line ~55), styled in static_src/scss/_game-kit.scss; toggled by apps/dashboard/static/.../game-kit.js. - The C3.b share-post async endpoint accepts Accept: application/json, returns {brief, line_text, recipient_display}; intercepted by the inline JS in post.html's existing #id_share_form. That JS moves into _bud_panel.html. - body.page-billboard class is set by billboard:billboard view; post.html needs it (or its own class) added in billboard.views.view_post. """ from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from apps.applets.models import Applet from apps.billboard.models import Brief, Line, Post from apps.lyric.models import User from .base import FunctionalTest def _seed_a_post(user): """Create a Post w. one Line so view_post renders w/o redirect.""" p = Post.objects.create(owner=user, title="seed line") Line.objects.create(post=p, text="seed line", author=user) return p class BudBtnPresenceTest(FunctionalTest): """The bud btn is post-only — present on post.html, absent elsewhere.""" def setUp(self): super().setUp() Applet.objects.get_or_create( slug="my-posts", defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, ) self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") # ── B1 ────────────────────────────────────────────────────────────────── def test_bud_btn_renders_on_post_html(self): self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake") self.assertIsNotNone(icon) # ── B2 ────────────────────────────────────────────────────────────────── def test_bud_btn_absent_on_dashboard(self): self.browser.get(self.live_server_url + "/") # Allow page to settle self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn")) # ── B3 ────────────────────────────────────────────────────────────────── def test_bud_btn_absent_on_billboard_index(self): self.browser.get(self.live_server_url + "/billboard/") self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn")) class BudBtnPositionTest(FunctionalTest): """The btn sits bottom-left, mirror of #id_kit_btn's bottom-right.""" def setUp(self): super().setUp() self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_bud_btn_is_fixed_bottom_left(self): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) cs = self.browser.execute_script( "var s = getComputedStyle(arguments[0]); " "return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};", btn, ) self.assertEqual(cs["position"], "fixed") # bottom + left are non-auto/0; right is auto (or large) so the btn # hugs the bottom-left corner. self.assertNotEqual(cs["bottom"], "auto") self.assertNotEqual(cs["left"], "auto") def test_bud_btn_size_matches_kit_btn(self): """Same circular-3rem look — visually a mirror pair.""" btn = self.browser.find_element(By.ID, "id_bud_btn") kit = self.browser.find_element(By.ID, "id_kit_btn") b_box = btn.size k_box = kit.size self.assertEqual(b_box["width"], k_box["width"]) self.assertEqual(b_box["height"], k_box["height"]) class BudBtnSlideOutTest(FunctionalTest): """Click the bud btn → recipient field + OK btn slide out under it.""" def setUp(self): super().setUp() self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_recipient_field_hidden_until_click(self): """Pre-click: the field is in DOM but visually closed (e.g. width 0 or transform scaleX(0)) — assertion checks it doesn't take its full viewport-spanning width yet.""" btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) # The field+OK panel is rendered (so JS can transition it) but should # be in a closed state — assert the panel container exists and the # input is not displayed at full width (a CSS-driven slide-out). panel = self.browser.find_element(By.ID, "id_bud_panel") # Before click, panel visible-width should be < viewport / 2 (closed) viewport_w = self.browser.execute_script("return window.innerWidth;") self.assertLess(panel.size["width"], viewport_w / 2) def test_click_bud_btn_reveals_recipient_field_and_ok_btn(self): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) self.assertTrue(recipient.is_displayed()) # OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") self.assertEqual(ok.text.strip().upper(), "OK") # Bud btn picks up .active when open (mirror kit-btn pattern) self.assertIn("active", btn.get_attribute("class")) def test_panel_spans_almost_full_viewport_when_open(self): """When open, the panel spans 100vw - 3rem (1.5rem each side).""" btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() panel = self.browser.find_element(By.ID, "id_bud_panel") # Wait for the slide-out transition to settle self.wait_for(lambda: self.assertGreater( panel.size["width"], self.browser.execute_script("return window.innerWidth;") * 0.7, )) def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) pad = self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).paddingLeft);", recipient, ) # At least 2.5rem (40px-ish) so the bud glyph (3rem circle) doesn't # overlap the placeholder/typed text. self.assertGreaterEqual(pad, 32) class BudKitMutualExclusionTest(FunctionalTest): """When kit btn is active, bud btn fades to 0 — and vice-versa.""" def setUp(self): super().setUp() self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_bud_active_fades_kit_btn(self): bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) kit = self.browser.find_element(By.ID, "id_kit_btn") bud.click() self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).opacity);", kit, ), 0.0, )) def test_kit_active_fades_bud_btn(self): kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) bud = self.browser.find_element(By.ID, "id_bud_btn") kit.click() self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).opacity);", bud, ), 0.0, )) class BudBtnDismissTest(FunctionalTest): """Click outside / Escape closes the panel; field is cleared; reopening shows the placeholder, not the previously-typed value.""" def setUp(self): super().setUp() self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def _open_and_type(self, text): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient.send_keys(text) return btn, recipient def test_click_outside_dismisses_and_clears(self): btn, recipient = self._open_and_type("alice@test.io") # Click somewhere far from the panel + btn body = self.browser.find_element(By.TAG_NAME, "body") self.browser.execute_script( "var e = new MouseEvent('click', {bubbles:true, clientX: 100, clientY: 100});" "document.querySelector('h2').dispatchEvent(e);" ) self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) # Reopening: input value should be cleared btn.click() recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) self.assertEqual(recipient.get_attribute("value"), "") def test_escape_dismisses_and_clears(self): btn, recipient = self._open_and_type("alice@test.io") recipient.send_keys(Keys.ESCAPE) self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) btn.click() recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) self.assertEqual(recipient.get_attribute("value"), "") class BudBtnOkSubmitsAsyncShareTest(FunctionalTest): """OK → POST share-post (Accept:application/json) → Brief banner + recipient chip appended; field clears; panel closes.""" def setUp(self): super().setUp() self.sharer = User.objects.create(email="bud@test.io") self.recipient = User.objects.create(email="alice@test.io") self.post = _seed_a_post(self.sharer) self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_ok_creates_brief_appends_line_and_chip(self): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient_input.send_keys("alice@test.io") ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") ok.click() # 1. Brief is created server-side self.wait_for(lambda: self.assertEqual( Brief.objects.filter(owner=self.sharer, kind=Brief.KIND_SHARE_INVITE).count(), 1, )) # 2. Banner appears self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")) # 3. .post-recipient chip appended self.wait_for(lambda: self.assertTrue( self.browser.find_elements(By.CSS_SELECTOR, ".post-recipient") )) # 4. Field cleared recipient_input = self.browser.find_element(By.ID, "id_recipient") self.assertEqual(recipient_input.get_attribute("value"), "") # 5. Panel closed (btn no longer .active) self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class"))) class PostHtmlAperturePageClassTest(FunctionalTest): """post.html and my_posts.html should pick up the aperture body class so they share the navbar/footer scroll-lock pattern w. sky.html etc.""" def setUp(self): super().setUp() Applet.objects.get_or_create( slug="my-posts", defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, ) self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) self.create_pre_authenticated_session("bud@test.io") def test_post_html_body_carries_billboard_or_post_page_class(self): self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) cls = body.get_attribute("class") self.assertTrue( "page-billboard" in cls or "page-billpost" in cls, f"post.html body class missing aperture marker: {cls!r}", ) def test_my_posts_html_body_carries_billboard_or_post_page_class(self): self.browser.get(self.live_server_url + f"/billboard/users/{self.gamer.id}/") body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) cls = body.get_attribute("class") self.assertTrue( "page-billboard" in cls or "page-billpost" in cls, f"my_posts.html body class missing aperture marker: {cls!r}", )