game-views carousel: red FTs for the ATLAS/SCROLL/POST/CHAT/PULSE sprint — TDD

Outside-in RED contract (test_game_room_views.py, game_room bucket) for the next sprint: the room's 2nd vertical snap pane becomes a horizontal carousel of 5 views reached by scrolling down from the hex — ATLAS | SCROLL | POST | CHAT | PULSE, landing on SCROLL (2nd).

Covers: a root-level icon strip (hidden at the hex, shown in the views pane, SCROLL active + glowing, 5 icons in order); icon-click + horizontal-wheel nav with glow handoff + a data-active-view title reveal; #id_text_btn running the down-then-right swipe machine from the hex to the Post view; the Post view as a room-scoped thread reusing post.html's #id_post_table + #id_post_line_text composer; the Atlas view aggregating GameEvents + room posts (data-source=provenance|post); CHAT/PULSE as .room-view-stub placeholders; and the landscape strip clearing the scroll card's fade mask.

No implementation yet — these fail until the feature lands; they are the build contract. Plan + decisions captured in memory (project-room-game-views-carousel). Builds on the GAME ROOM ⇄ GAME SCROLL title reel + the room scroll-of-events aperture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 01:49:22 -04:00
parent cf1965c439
commit 39a42a33a3

View File

@@ -0,0 +1,231 @@
"""Game-views carousel — RED outside-in FTs (planned 2026-06-02, build next).
The room's 2nd vertical scroll-snap pane (the Game Scroll) becomes a HORIZONTAL
carousel of five views, reached by scrolling DOWN from the table-hex:
ATLAS ◀ SCROLL ▶ POST ▶ CHAT ▶ PULSE
(book-atlas) (scroll) (keyboard) (comments) (chart-pie)
- A root-level ICON STRIP sits centred at the aperture bottom (above the scroll
card's fade mask), shown only while the views pane is in view. The active
view's icon carries the --ninUser/--terUser glow; the others are --secUser @0.6.
SCROLL is the 2nd icon and the default landing view.
- Nav: native horizontal scroll-snap + horizontal mouse-wheel (and shift+wheel);
vertical wheel stays reserved for scrolling each feed. Icon-click also snaps.
- The h2 title reel gains a horizontal axis: the 2nd word cycles
ROOM ⇄ SCROLL/POST/ATLAS/CHAT/PULSE (built on the existing .gr-swap reel).
- The burger fan's #id_text_btn leads to POST: from the hex it swipes DOWN to
the views pane then RIGHT to the Post view (the "swipe machine").
- POST is a room-scoped thread reusing post.html's #id_post_table + the
#id_post_line_text composer. ATLAS aggregates GameEvents + the room's posts,
time-merged, each source styled distinctly. CHAT + PULSE are stubs this sprint.
These tests fail until the feature lands — they ARE the contract for the build.
FT bucket: game_room. IT/UT coverage (view context, room↔Post link, aggregate
merge) is written beneath these as the build proceeds.
"""
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .room_page import _equip_earthman_deck, _fill_room_via_orm
from apps.drama.models import GameEvent
from apps.epic.models import Room
from apps.lyric.models import User
VIEW_ORDER = ["atlas", "scroll", "post", "chat", "pulse"]
class GameViewsCarouselTest(FunctionalTest):
def setUp(self):
super().setUp()
self.viewer = User.objects.create(email="disco@test.io", username="disco")
_equip_earthman_deck(self.viewer)
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
_fill_room_via_orm(
self.room,
["disco@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"],
)
# A provenance line so the Scroll + Atlas feeds have content.
GameEvent.objects.create(
room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,
data={"token_type": "carte", "token_display": "Carte Blanche",
"slot_number": 1, "renewal_days": 7},
)
self.room.table_status = Room.ROLE_SELECT
self.room.gate_status = Room.OPEN
self.room.save()
self.url = self.live_server_url + f"/gameboard/room/{self.room.id}/"
# ── helpers ──────────────────────────────────────────────────────────
def _open(self, size=(820, 600)):
self.create_pre_authenticated_session("disco@test.io")
self.browser.set_window_size(*size)
self.browser.get(self.url)
return self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".room-aperture"))
def _scroll_to_views(self):
# Drop the aperture onto its 2nd vertical snap pane (the views carousel).
self.browser.execute_script(
"document.querySelector('.room-aperture').scrollTop = 99999;")
self.wait_for(lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_room_views_strip").is_displayed()))
def _in_viewport(self, css):
return self.browser.execute_script(
"var e=document.querySelector(arguments[0]); if(!e) return false;"
"var r=e.getBoundingClientRect();"
"return r.left < window.innerWidth && r.right > 0"
" && r.top < window.innerHeight && r.bottom > 0;", css)
def _active_view(self):
return self.browser.find_element(
By.CSS_SELECTOR, ".room-view-icon.is-active").get_attribute("data-view")
def _click_icon(self, view):
self.browser.execute_script(
"document.querySelector(arguments[0]).click();",
f".room-view-icon[data-view='{view}']")
# ── tests ────────────────────────────────────────────────────────────
def test_icon_strip_hidden_at_hex_shown_in_views_with_scroll_active(self):
"""At the table-hex the strip is hidden; scrolling down reveals it with
five icons in order [atlas, scroll, post, chat, pulse] and SCROLL (2nd)
active + glowing."""
self._open()
self.assertFalse(
self.browser.find_element(By.ID, "id_room_views_strip").is_displayed())
self._scroll_to_views()
icons = self.browser.find_elements(
By.CSS_SELECTOR, "#id_room_views_strip .room-view-icon")
self.assertEqual([i.get_attribute("data-view") for i in icons], VIEW_ORDER)
# SCROLL is the default landing view + carries the active glow class.
self.assertEqual(self._active_view(), "scroll")
scroll_icon = self.browser.find_element(
By.CSS_SELECTOR, ".room-view-icon[data-view='scroll']")
self.assertIn("fa-scroll", scroll_icon.get_attribute("innerHTML"))
def test_clicking_icon_snaps_view_moves_glow_and_title(self):
"""Clicking the POST icon snaps to the Post view, hands the glow to the
post icon, and advances the title reel to GAME POST."""
self._open()
self._scroll_to_views()
self.assertTrue(self._in_viewport(".room-view[data-view='scroll']"))
self._click_icon("post")
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='post']")))
self.assertEqual(self._active_view(), "post")
# Glow handoff: scroll icon is no longer active.
self.assertNotIn("is-active", self.browser.find_element(
By.CSS_SELECTOR, ".room-view-icon[data-view='scroll']"
).get_attribute("class"))
# Title reel reveals the active view's name.
self.assertEqual(self.browser.find_element(
By.CSS_SELECTOR, ".row .col-lg-6 h2").get_attribute("data-active-view"),
"post")
def test_horizontal_wheel_advances_to_next_view(self):
"""A horizontal wheel tick (deltaX) advances to the next view in line —
the desktop-mouse equivalent of a swipe. Vertical wheel must NOT change
the view (it scrolls the feed)."""
self._open()
self._scroll_to_views()
self.assertEqual(self._active_view(), "scroll")
# Horizontal wheel right → next view (post).
self.browser.execute_script(
"document.querySelector('#id_room_views').dispatchEvent("
"new WheelEvent('wheel', {deltaX: 120, deltaY: 0, bubbles: true}));")
self.wait_for(lambda: self.assertEqual(self._active_view(), "post"))
def test_text_btn_from_hex_lands_on_post_view(self):
"""The burger fan's #id_text_btn (fa-keyboard) is active on the table;
clicking it from the hex runs the swipe machine — DOWN to the views pane
then RIGHT to the Post view (the 3rd icon)."""
self._open()
# Burger fan: open it, then click the active Text sub-btn.
self.browser.execute_script(
"document.getElementById('id_burger_btn').click();")
text_btn = self.browser.find_element(By.ID, "id_text_btn")
self.assertIn("active", text_btn.get_attribute("class"))
self.browser.execute_script("arguments[0].click();", text_btn)
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='post']")))
self.assertEqual(self._active_view(), "post")
def test_post_view_is_room_thread_with_working_composer(self):
"""The Post view embeds the room-scoped post thread: the #id_post_line_text
composer appends a line to #id_post_table that everyone at the table reads."""
self._open()
self._scroll_to_views()
self._click_icon("post")
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='post'] #id_post_line_text")))
box = self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_text")
box.send_keys("lets gooo")
self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_btn").click()
self.wait_for(lambda: self.assertIn(
"lets gooo",
self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_table").text))
def test_atlas_aggregates_provenance_and_posts(self):
"""The Atlas view merges the room's GameEvents AND its post-lines into one
time-ordered feed, each source carrying a distinct data-source so per-type
styling can land at the end of the sprint."""
self._open()
self._scroll_to_views()
# Post a line first so Atlas has a post-source row to merge.
self._click_icon("post")
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='post'] #id_post_line_text")))
box = self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_text")
box.send_keys("door creaks open")
self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_btn").click()
self._click_icon("atlas")
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='atlas']")))
atlas = self.browser.find_element(
By.CSS_SELECTOR, ".room-view[data-view='atlas']")
# Both a provenance row and a post row are present, distinctly sourced.
self.assertIn("deposits a Carte Blanche", atlas.text)
self.assertIn("door creaks open", atlas.text)
self.assertTrue(atlas.find_elements(
By.CSS_SELECTOR, "[data-source='provenance']"))
self.assertTrue(atlas.find_elements(
By.CSS_SELECTOR, "[data-source='post']"))
def test_chat_and_pulse_render_as_stubs(self):
"""CHAT (fa-comments) + PULSE (fa-chart-pie) are stub views this sprint —
each renders a placeholder, no backing model yet."""
self._open()
self._scroll_to_views()
for view in ("chat", "pulse"):
self._click_icon(view)
self.wait_for(lambda v=view: self.assertTrue(
self._in_viewport(f".room-view[data-view='{v}']")))
self.assertTrue(self.browser.find_element(
By.CSS_SELECTOR,
f".room-view[data-view='{view}'] .room-view-stub").is_displayed())
def test_icon_strip_clears_the_aperture_mask_in_landscape(self):
"""In landscape the strip sits at the aperture bottom, above the scroll
card's fade mask (not obscured) — its box stays within the viewport."""
self._open(size=(940, 520))
self._scroll_to_views()
strip = self.browser.find_element(By.ID, "id_room_views_strip")
self.assertTrue(strip.is_displayed())
within = self.browser.execute_script(
"var r=arguments[0].getBoundingClientRect();"
"return r.bottom <= window.innerHeight && r.top >= 0"
" && r.right <= window.innerWidth && r.left >= 0;", strip)
self.assertTrue(within)