"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21. The SEED MAP felt is the inline --duoUser sibling of the CAST SKY / DRAW SEA felts: once the seat's Celtic-Cross hand is complete (hand_complete), SEED MAP is the live hex phase btn, and clicking it opens a felt that paints a 2D d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2), so we assert only that the dual graph renders both layers. Step 2's first piece — the SHARED WHEEL RIM: the stripped sky wheel (signs ring at canonical orientation + planet glyphs; NO element ring, centre disc, houses or axes — a virtual table has a convened TIME but no birth LOCATION) rings the tessellation, drawn from the ROOM'S OWN sky (Room.sky_chart, identical for all six gamers). The map svg shrinks into the wheel's freed hub. The fixture pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP. Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side. We seed a CONFIRMED Character with a complete hand directly in the DB, so there is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs. """ from django.urls import reverse from django.utils import timezone from selenium.webdriver.common.by import By from apps.epic.models import Character, DeckVariant, TarotCard from apps.lyric.models import User from .base import FunctionalTest from .test_game_room_select_sea import _make_sky_confirmed_room _HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"] # The room's own sky — planets only (location-independent), mirroring the # Jasmine ROOM_SKY fixture. Mercury retrograde exercises the ℞ badge. _ROOM_SKY = { "planets": { "Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}, "Moon": {"sign": "Capricorn", "degree": 295.1, "retrograde": False}, "Mercury": {"sign": "Aquarius", "degree": 312.8, "retrograde": True}, }, "aspects": [], } class SeedMapFeltTest(FunctionalTest): def setUp(self): super().setUp() self.browser.set_window_size(800, 1200) # portrait — clicking hex-area btns self.earthman, _ = DeckVariant.objects.get_or_create( slug="earthman", defaults={"name": "Earthman", "card_count": 106, "is_default": True, "is_polarized": True, "has_card_images": False}, ) # One card is enough: hand_complete is purely len(hand) >= 6, and STEP-1 # rendering uses PLACEHOLDER seeds, so card identity is irrelevant. Seed # it explicitly — TransactionTestCase flushes migration cards (no # serialized_rollback). [[feedback-transactiontestcase-flush]] self.card, _ = TarotCard.objects.get_or_create( deck_variant=self.earthman, slug="seed-fixture-em", defaults={"arcana": "MAJOR", "suit": None, "number": 0, "name": "Fixture"}, ) self.gamer, _ = User.objects.get_or_create(email="founder@test.io") self.gamer.unlocked_decks.add(self.earthman) self.gamer.equipped_deck = self.earthman self.gamer.save(update_fields=["equipped_deck"]) def _seed_room(self, hand_len=6): """SKY_SELECT room, founder seated PC, with a CONFIRMED Character whose celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete => SEED MAP is the live phase btn + the felt renders.""" room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman) # Pre-store the table's own sky so the rim renders without the lazy # PySwiss compute (no live HTTP from FTs). room.sky_chart = _ROOM_SKY room.save(update_fields=["sky_chart"]) hand = [ {"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"} for p in _HAND_POSITIONS[:hand_len] ] Character.objects.create( seat=seat, significator=seat.significator or self.card, chart_data={"planets": {}}, confirmed_at=timezone.now(), celtic_cross={"spread": "waite-smith", "hand": hand}, ) return room def _room_url(self, room): return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id}) def _open_seed_felt(self, room): """Load the room, wait for SEED MAP to go live, click it open.""" self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) # hand_complete → SEED MAP is the live phase btn (loses hex-phase-btn--out). self.wait_for(lambda: self.assertNotIn( "hex-phase-btn--out", self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"), )) # Click opens the felt. JS-click + retry absorbs the parse-time bind race # (mirrors _click_and_assert_sea_open in test_game_room_select_sea.py). def _click_and_assert_open(): btn = self.browser.find_element(By.ID, "id_seed_map_btn") self.browser.execute_script("arguments[0].click()", btn) self.assertTrue(self.browser.execute_script( "return document.documentElement.classList.contains('seed-open')" )) self.wait_for(_click_and_assert_open) def test_seed_map_felt_opens_and_paints_dual_graph(self): room = self._seed_room(hand_len=6) self._open_seed_felt(room) self.assertTrue( self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed() ) # The d3-delaunay dual graph paints BOTH layers from placeholder seeds: # Voronoi cells (territory) + Delaunay edges (adjacency). self.wait_for(lambda: self.assertGreaterEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length" ), 1, )) self.assertGreaterEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_map_svg .delaunay-edge').length" ), 1, ) def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self): """Step 2's shared frame: the stripped sky wheel — canonical signs ring + the ROOM's own planets, NOTHING location-bound (no houses/axes) and none of the stripped chrome (element ring / centre disc / aspect web) — rings the tessellation, which shrinks into the wheel's freed hub.""" room = self._seed_room(hand_len=6) self._open_seed_felt(room) # The rim paints: 12 canonical sign slices + the room sky's 3 planets. self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length" ), 12, )) self.assertEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length" ), 3, ) # The strip: no element ring / centre disc / aspect web, and no # location-bound layers (houses, ASC/MC axes) on a shared rim. for cls in ["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"]: self.assertEqual( self.browser.execute_script( f"return document.querySelectorAll('#id_seed_wheel_svg .{cls}').length" ), 0, f"expected no .{cls} on the shared rim", ) # The tessellation sits INSIDE the rim: the map svg is shrunk square # into the wheel's hub (2 × hubR = 2 × 0.52 × 0.46 × wheel span) and # carries the rimmed clip class. map_w, map_h, col_w, col_h = self.browser.execute_script( "const m = document.getElementById('id_seed_map_svg')," " c = m.closest('.seed-map-col');" "return [m.clientWidth, m.clientHeight, c.clientWidth, c.clientHeight];" ) self.assertEqual(map_w, map_h) # square self.assertLess(map_w, min(col_w, col_h)) # inside the wheel self.assertAlmostEqual( # = the freed hub map_w, 2 * 0.52 * 0.46 * min(col_w, col_h), delta=2, ) self.assertIn("voronoi-map--rimmed", self.browser.find_element(By.ID, "id_seed_map_svg").get_attribute("class")) # And the dual graph still paints inside the hub. self.assertGreaterEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length" ), 1, ) def test_seed_map_felt_absent_until_hand_complete(self): room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) self.wait_for(lambda: self.browser.find_element(By.ID, "id_seed_map_btn")) # SEED MAP stays --out and the felt is never injected into the DOM. self.assertIn( "hex-phase-btn--out", self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"), ) self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])