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:
231
src/functional_tests/test_game_room_views.py
Normal file
231
src/functional_tests/test_game_room_views.py
Normal 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)
|
||||||
Reference in New Issue
Block a user