diff --git a/CLAUDE.md b/CLAUDE.md index 13340d4..26a3453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,43 +3,10 @@ Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book. ## Browser Integration -**Claudezilla** is installed — a Firefox extension + native host that lets Claude observe and interact with the browser directly. +**Claudezilla** is installed — a Firefox extension + native host for browser automation. +See `.claude/skills/claudezilla-browser/SKILL.md` for tool list, startup protocol, and setup reference. -### Tool names -Tools are available as `mcp__claudezilla__firefox_*`, e.g.: -- `mcp__claudezilla__firefox_screenshot` — capture current tab -- `mcp__claudezilla__firefox_navigate` — navigate to URL -- `mcp__claudezilla__firefox_get_page_state` — structured JSON (faster than screenshot) -- `mcp__claudezilla__firefox_create_window` — open new tab (returns `tabId`) -- `mcp__claudezilla__firefox_diagnose` — check connection status -- `mcp__claudezilla__firefox_set_private_mode` — disable private mode to use session cookies - -All tools require a `tabId` except `firefox_create_window` and `firefox_diagnose`. - -### If tools aren't available in a session -MCP servers load at session startup only. **Start a new Claude Code conversation** (hit "+" in the sidebar) — no need to reboot VSCode, just open a fresh chat. Always call `firefox_diagnose` first to confirm the connection is live. - -### Correct startup sequence -1. Firefox open with Claudezilla extension active (native host must be running) -2. Open a new Claude Code conversation → tools appear as `mcp__claudezilla__firefox_*` -3. Call `firefox_diagnose` to confirm before depending on any tool - -### Setup (already done — for reference) -The native messaging host requires a `.bat` wrapper on Windows (Firefox can't execute `.js` directly): -- Wrapper: `E:\ClaudeLibrary\claudezilla\host\claudezilla.bat` — contains `@echo off` / `node "%~dp0index.js" %*` -- Manifest: `C:\Users\adamc\AppData\Roaming\claudezilla\claudezilla.json` — points to the `.bat` file -- Registry: `HKCU\SOFTWARE\Mozilla\NativeMessagingHosts\claudezilla` → manifest path -- MCP server: registered in `~/.claude.json` (NOT `~/.claude/settings.json` or `~/.claude/mcp.json`) — use the CLI to register: - ``` - claude mcp add --scope user claudezilla "D:/Program Files/nodejs/node.exe" "E:/ClaudeLibrary/claudezilla/mcp/server.js" - ``` -- Permission: `mcp__claudezilla__*` in `~/.claude/settings.json` `permissions.allow` - -**Config file gotcha:** The Claude Code CLI and VSCode extension read user-level MCP servers from `~/.claude.json` (home dir, single file) — NOT from `~/.claude/settings.json` or `~/.claude/mcp.json`. Always use `claude mcp add --scope user` to register; never hand-edit. Verify registration with `claude mcp list`. - -**BOM gotcha:** PowerShell writes JSON files with a UTF-8 BOM, which causes `JSON.parse` to throw. Never use PowerShell `Set-Content` to write any Claude config JSON — use the Write tool or the CLI instead. - -Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`. +**STARTUP RULE:** Call `mcp__claudezilla__firefox_diagnose` at the start of every conversation before any browser tool. If tools aren't listed in a session, open a new Claude Code conversation (MCP servers load at startup only). ## Stack - **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn) @@ -87,62 +54,66 @@ Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory. cd src uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src -# Integration + unit tests only (from project root — targets src/apps, skips src/functional_tests) -python src/manage.py test src/apps +# Integration + unit tests (exclude channels) +python src/manage.py test src/apps --exclude-tag=channels -# Functional tests only +# Functional tests python src/manage.py test src/functional_tests - -# All tests (integration + unit + FT) -python src/manage.py test src ``` -### Multi-user manual testing — `setup_sig_session` -`src/functional_tests/management/commands/setup_sig_session.py` +See `.claude/skills/TDD/SKILL.md` for the full TDD cycle, test file conventions, base classes, and per-layer run commands. See `.claude/skills/dev-server/SKILL.md` for server startup options. -Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs. +### Multi-user manual testing — `setup_sig_session` + +Creates (or reuses) a room at `table_status=SIG_SELECT` with all 6 slots filled. Prints one pre-auth URL per gamer. ```bash python src/manage.py setup_sig_session python src/manage.py setup_sig_session --base-url http://localhost:8000 -python src/manage.py setup_sig_session --room # reuse existing room +python src/manage.py setup_sig_session --room ``` -Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login//` pre-auth pattern. +Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/lyric/dev-login//` pre-auth pattern. -**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer. -- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels` -- Channels tests only: `python src/manage.py test src/apps --tag=channels` - -FTs are isolated by **directory path** (`src/functional_tests/`), not by tag. - -Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic. - -## CI/CD +## CI/CD + Hosting - Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`) - Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me` -- Push to `main` triggers Woodpecker → deploys to staging +- Push to `main` triggers Woodpecker → deploys to staging (`staging.earthmanrpg.me`) +- Prod deploy: `git tag v1.0.0 && git push --tags` → triggers `deploy-prod` step (tag-based gate) +- Two CI pipelines run in parallel: `.woodpecker/main.yaml` (main app) + `.woodpecker/pyswiss.yaml` (PySwiss at charts.earthmanrpg.me) +- Multi-browser FTs tagged `@tag("two-browser")` run in a dedicated CI stage (`test-two-browser-FTs`) alongside `--tag=channels`; `test-FTs` stage is parallel-only +- Hosting: DigitalOcean — main app on staging droplet; PySwiss on separate droplet (167.172.154.66) +- Email: Mailgun (`adman@howdy.earthmanrpg.me`) | DNS: NameCheap + +## UI / Layout Conventions + +### Sidebar layout (`$sidebar-w: 4rem`) +Navbar is a fixed left sidebar; footer is a fixed right sidebar. Both are `4rem` wide. Main container uses `margin-left: $sidebar-w; margin-right: $sidebar-w`. Landscape layout resets `min-width` to `0` on `.gameboard-page` and `#id_dash_content` (override of the `@media (min-width: 738px)` block that sets `min-width: 666px`). + +### Applet headings + page titles +- Section headings: plain `

` — browser default + body color inherited; no extra SCSS needed +- Clickable headings: `

Text

` — global `body a` rule supplies gold + hover glow +- Page titles: `Dashsuffix` pattern (Dashwallet, Dashnote, Dashnotes) + +### Position vs Seat terminology +Circles around the table hex are **positions** (gate slot order, 1–6). After role assignment they become **seats** (PC→NC→EC→SC→AC→BC). CSS carries both: `.table-seat.table-position`. `SLOT_ROLE_LABELS = {1:"PC", 2:"NC", 3:"EC", 4:"SC", 5:"AC", 6:"BC"}` in `epic/views.py`. + +## Game Architecture + +### Token priority chain +`select_token(user)` in `apps.epic.models`: **PASS → COIN → FREE → TITHE → None**. `debit_token` handles each type's consumption rules (Coin cooldown, Free/Tithe expiry). + +### Two-step gate token flow +Drop → RESERVED → confirm/reject. `_gate_context()` builds slot state; `_expire_reserved_slots()` clears stale reservations after 60s. Views: `confirm_token`, `reject_token` (renamed `return_token`). + +### Room URL routing +`epic:room` view at `/gameboard/room//`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`. ## SCSS Import Order `core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens` ## Critical Gotchas -### TransactionTestCase flushes migration data -FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving. - -### Static files in tests -`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps//static/` or `src/static_src/`. - -### msgpack integer key bug (Django Channels) -`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys. - -### Multi-browser FTs in CI -Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`. - -### Selenium + CSS text-transform -Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`. - ### Tooltip portal pattern `mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`. @@ -157,5 +128,16 @@ Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` - Task unit tests: `apps.lyric.tasks.requests.post` - FTs: mock both with `side_effect=send_login_email_task` -## Teaching Style -User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly. +### game-kit.js selection persistence +`window._kitTokenId` must NOT be cleared on kit-bag close — users close the dialog before clicking the rails button. Selection persists until page navigation. No `clearSelection()` in `game-kit.js`. + +### Billboard timezone cookie +`document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone` — **no `encodeURIComponent`**. Slashes in TZ names (`America/New_York`) are cookie-safe; encoding breaks the `ZoneInfo` lookup in `TimezoneMiddleware`. + +### CSS `:has()` for child-dependent styling +Use `.parent:has(.child-class)` to style a parent based on its contents without template changes. Example: `.gate-slot:has(.drop-token-btn)` makes CARTE OK-button circles match `.reserved` circles. + +### Plausible FT noise +Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag. + +See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys). diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py index 9aecbee..017ba15 100644 --- a/src/apps/dashboard/tests/integrated/test_sky_views.py +++ b/src/apps/dashboard/tests/integrated/test_sky_views.py @@ -1,8 +1,8 @@ """Integration tests for the My Sky dashboard views. sky_view — GET /dashboard/sky/ → renders sky template -sky_preview — GET /dashboard/sky/preview/ → proxies to PySwiss (no DB write) -sky_save — POST /dashboard/sky/save/ → saves natal data to User model +sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write) +sky_save — POST /dashboard/sky/save → saves natal data to User model """ import json @@ -113,14 +113,13 @@ class SkySaveTest(TestCase): self.assertEqual(response.status_code, 405) def test_saves_sky_fields_to_user(self): - chart = {"planets": {}, "houses": {}, "elements": {}} payload = { "birth_dt": "1990-06-15T08:30:00", "birth_lat": 51.5074, "birth_lon": -0.1278, "birth_place": "London, UK", "house_system": "O", - "chart_data": chart, + "chart_data": {}, } response = self._post(payload) self.assertEqual(response.status_code, 200) @@ -131,7 +130,6 @@ class SkySaveTest(TestCase): self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3) self.assertEqual(self.user.sky_birth_place, "London, UK") self.assertEqual(self.user.sky_house_system, "O") - self.assertEqual(self.user.sky_chart_data, chart) def test_invalid_json_returns_400(self): response = self.client.post( diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 3f97b6a..160de63 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -1,4 +1,6 @@ +import json import lxml.html +from unittest.mock import patch, MagicMock from django.contrib.messages import get_messages from django.test import override_settings, TestCase @@ -482,3 +484,117 @@ class WalletAppletTest(TestCase): def test_wallet_applet_has_manage_link(self): [link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link") self.assertEqual(link.get("href"), "/dashboard/wallet/") + + +ENRICHED_CHART = { + "planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}}, + "houses": {"cusps": [float(i * 30) for i in range(12)]}, + "elements": { + "Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]}, + "Stone": {"count": 1, "contributors": ["Venus"]}, + "Air": {"count": 2, "contributors": ["Mercury", "Uranus"]}, + "Water": {"count": 0, "contributors": []}, + "Time": {"count": 1, "stellia": ["Saturn"]}, + "Space": {"count": 1, "parades": ["Neptune"]}, + }, + "distinctions": [], + "timezone": "UTC", +} + +BIRTH_PAYLOAD = { + "birth_dt": "1990-06-15T12:00:00Z", + "birth_lat": 51.5, + "birth_lon": -0.1, + "birth_place": "London", + "house_system": "O", + "chart_data": {"stale": True}, +} + + +@override_settings(PYSWISS_URL="http://pyswiss-test") +class SkySaveViewTest(TestCase): + def setUp(self): + self.user = User.objects.create( + email="disco@test.io", + sky_birth_lat=51.5, + sky_birth_lon=-0.1, + sky_birth_place="London", + ) + self.client.force_login(self.user) + + def _post(self, payload=None): + return self.client.post( + "/dashboard/sky/save", + data=json.dumps(payload or BIRTH_PAYLOAD), + content_type="application/json", + ) + + @patch("apps.dashboard.views.http_requests.get") + def test_save_fetches_enriched_data_from_pyswiss(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = dict(ENRICHED_CHART) + mock_get.return_value = mock_resp + + response = self._post() + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + saved = self.user.sky_chart_data + self.assertIsNotNone(saved) + fire = saved["elements"]["Fire"] + self.assertIn("contributors", fire) + self.assertIn("Sun", fire["contributors"]) + + @patch("apps.dashboard.views.http_requests.get") + def test_save_falls_back_to_client_data_if_pyswiss_unreachable(self, mock_get): + mock_get.side_effect = Exception("connection refused") + client_chart = {"planets": {}, "elements": {"Fire": 2}} + payload = dict(BIRTH_PAYLOAD, chart_data=client_chart) + + response = self._post(payload) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.sky_chart_data, client_chart) + + +@override_settings(PYSWISS_URL="http://pyswiss-test") +class SkyNatusDataViewTest(TestCase): + def setUp(self): + self.user = User.objects.create( + email="disco@test.io", + sky_birth_lat=51.5, + sky_birth_lon=-0.1, + sky_birth_place="London", + ) + self.client.force_login(self.user) + + @patch("apps.dashboard.views.http_requests.get") + def test_returns_enriched_chart_data(self, mock_get): + from datetime import datetime, timezone as dt_timezone + self.user.sky_birth_dt = datetime(1990, 6, 15, 12, 0, tzinfo=dt_timezone.utc) + self.user.sky_house_system = "O" + self.user.save(update_fields=["sky_birth_dt", "sky_house_system"]) + + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = dict(ENRICHED_CHART) + mock_get.return_value = mock_resp + + response = self.client.get("/dashboard/sky/data") + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn("elements", data) + fire = data["elements"]["Fire"] + self.assertIn("contributors", fire) + + def test_returns_404_if_no_birth_data_saved(self): + response = self.client.get("/dashboard/sky/data") + self.assertEqual(response.status_code, 404) + + def test_requires_login(self): + self.client.logout() + response = self.client.get("/dashboard/sky/data") + self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False) diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index d58351d..7f6d56f 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), path('kit-bag/', views.kit_bag, name='kit_bag'), path('sky/', views.sky_view, name='sky'), - path('sky/preview/', views.sky_preview, name='sky_preview'), - path('sky/save/', views.sky_save, name='sky_save'), + path('sky/preview', views.sky_preview, name='sky_preview'), + path('sky/save', views.sky_save, name='sky_save'), + path('sky/data', views.sky_natus_data, name='sky_natus_data'), ] diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 04bc787..1811019 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -301,8 +301,8 @@ def _sky_natus_preview(request): @login_required(login_url="/") def sky_view(request): return render(request, "apps/dashboard/sky.html", { - "preview_url": request.build_absolute_uri("/dashboard/sky/preview/"), - "save_url": request.build_absolute_uri("/dashboard/sky/save/"), + "preview_url": request.build_absolute_uri("/dashboard/sky/preview"), + "save_url": request.build_absolute_uri("/dashboard/sky/save"), "saved_sky": request.user.sky_chart_data, "saved_birth_dt": request.user.sky_birth_dt, "saved_birth_place": request.user.sky_birth_place, @@ -327,21 +327,72 @@ def sky_save(request): user = request.user birth_dt_str = body.get('birth_dt', '') + birth_dt_utc = None if birth_dt_str: try: naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00')) user.sky_birth_dt = naive if naive.tzinfo else naive.replace( tzinfo=zoneinfo.ZoneInfo('UTC') ) + birth_dt_utc = user.sky_birth_dt.astimezone(zoneinfo.ZoneInfo('UTC')) except ValueError: user.sky_birth_dt = None user.sky_birth_lat = body.get('birth_lat') user.sky_birth_lon = body.get('birth_lon') user.sky_birth_place = body.get('birth_place', '') user.sky_house_system = body.get('house_system', 'O') - user.sky_chart_data = body.get('chart_data') + + lat_str = body.get('birth_lat') + lon_str = body.get('birth_lon') + if birth_dt_utc and lat_str is not None and lon_str is not None: + try: + dt_iso = birth_dt_utc.strftime('%Y-%m-%dT%H:%M:%SZ') + resp = http_requests.get( + settings.PYSWISS_URL + '/api/chart/', + params={'dt': dt_iso, 'lat': str(lat_str), 'lon': str(lon_str)}, + timeout=5, + ) + resp.raise_for_status() + enriched = resp.json() + if 'elements' in enriched and 'Earth' in enriched['elements']: + enriched['elements']['Stone'] = enriched['elements'].pop('Earth') + user.sky_chart_data = enriched + except Exception: + user.sky_chart_data = body.get('chart_data') + else: + user.sky_chart_data = body.get('chart_data') + user.save(update_fields=[ 'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon', 'sky_birth_place', 'sky_house_system', 'sky_chart_data', ]) return JsonResponse({"saved": True}) + + +@login_required(login_url="/") +def sky_natus_data(request): + user = request.user + if not user.sky_birth_lat or not user.sky_birth_lon or not user.sky_birth_dt: + return HttpResponse(status=404) + + try: + utc_dt = user.sky_birth_dt.astimezone(zoneinfo.ZoneInfo('UTC')) + dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ') + resp = http_requests.get( + settings.PYSWISS_URL + '/api/chart/', + params={ + 'dt': dt_iso, + 'lat': str(user.sky_birth_lat), + 'lon': str(user.sky_birth_lon), + }, + timeout=5, + ) + resp.raise_for_status() + except Exception: + return HttpResponse(status=502) + + data = resp.json() + if 'elements' in data and 'Earth' in data['elements']: + data['elements']['Stone'] = data['elements'].pop('Earth') + data['distinctions'] = _compute_distinctions(data['planets'], data['houses']) + return JsonResponse(data) diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js index ea53f7e..384d33d 100644 --- a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -119,10 +119,12 @@ const NatusWheel = (() => { // ── Cycle state ──────────────────────────────────────────────────────────── - let _activeRing = null; // 'planets' | 'elements' | null + let _activeRing = null; // 'planets' | 'elements' | 'signs' | 'houses' | null let _activeIdx = null; // index within the active ring's sorted list let _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending let _elementItems = []; // [{key}] in ELEMENT_ORDER + let _signItems = []; // [{name, symbol, element}] in SIGNS order + let _houseItems = []; // [{num, label}] houses 1–12 // Tooltip DOM refs — set by _injectTooltipControls() on each draw(). let _tooltipEl = null; @@ -233,6 +235,11 @@ const NatusWheel = (() => { .map(([name, p]) => ({ name, degree: p.degree })) .sort((a, b) => b.degree - a.degree); // descending = clockwise on wheel _elementItems = ELEMENT_ORDER.map(key => ({ key })); + _signItems = SIGNS.map(s => ({ name: s.name, symbol: s.symbol, element: s.element })); + _houseItems = Array.from({ length: 12 }, (_, i) => ({ + num: i + 1, + label: HOUSE_LABELS[i + 1], + })); } /** Clear all active-lock classes and reset cycle state. */ @@ -320,9 +327,15 @@ const NatusWheel = (() => { if (ring === 'planets') { const grp = svgNode.querySelector(`[data-planet="${_planetItems[idx].name}"]`); el = grp && (grp.querySelector('circle') || grp); - } else { + } else if (ring === 'elements') { const grp = svgNode.querySelector(`[data-element="${_elementItems[idx].key}"]`); el = grp && (grp.querySelector('path') || grp); + } else if (ring === 'signs') { + const grp = svgNode.querySelector(`[data-sign-name="${_signItems[idx].name}"]`); + el = grp && (grp.querySelector('path') || grp); + } else if (ring === 'houses') { + const grp = svgNode.querySelector(`[data-house="${_houseItems[idx].num}"]`); + el = grp && (grp.querySelector('path') || grp); } if (el) iRect = el.getBoundingClientRect(); } @@ -434,6 +447,8 @@ const NatusWheel = (() => { bodyHtml += `
${psym} @ ${inDeg}° ${sicon} +1
`; }); bodyHtml += ''; + } else { + bodyHtml += `
`; } } else if (item.key === 'Time') { @@ -506,6 +521,43 @@ const NatusWheel = (() => { } } + function _activateSign(idx) { + _activeRing = 'signs'; + _activeIdx = idx; + const sign = _signItems[idx]; + if (_ttBody) { + _ttBody.innerHTML = + `
` + + `${sign.symbol}` + + `${sign.name}` + + ` · ${sign.element}` + + `
`; + } + _positionTooltipAtItem('signs', idx); + if (_tooltipEl) { + _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none'); + _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none'); + } + } + + function _activateHouse(idx) { + _activeRing = 'houses'; + _activeIdx = idx; + const house = _houseItems[idx]; + if (_ttBody) { + _ttBody.innerHTML = + `
` + + `${house.num}` + + ` · ${house.label}` + + `
`; + } + _positionTooltipAtItem('houses', idx); + if (_tooltipEl) { + _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none'); + _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none'); + } + } + /** Advance the active ring by +1 (NXT) or -1 (PRV). */ function _stepCycle(dir) { if (_activeRing === 'planets') { @@ -514,6 +566,12 @@ const NatusWheel = (() => { } else if (_activeRing === 'elements') { _activeIdx = (_activeIdx + dir + _elementItems.length) % _elementItems.length; _activateElement(_activeIdx); + } else if (_activeRing === 'signs') { + _activeIdx = (_activeIdx + dir + _signItems.length) % _signItems.length; + _activateSign(_activeIdx); + } else if (_activeRing === 'houses') { + _activeIdx = (_activeIdx + dir + _houseItems.length) % _houseItems.length; + _activateHouse(_activeIdx); } } @@ -661,7 +719,20 @@ const NatusWheel = (() => { const endA = _toAngle(endDeg, asc); const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA]; - sigGroup.append('path') + const signSlice = sigGroup.append('g') + .attr('class', `nw-sign-group`) + .attr('data-sign-name', sign.name) + .on('click', function (event) { + event.stopPropagation(); + const clickIdx = _signItems.findIndex(s => s.name === sign.name); + if (_activeRing === 'signs' && _activeIdx === clickIdx) { + _closeTooltip(); + } else { + _activateSign(clickIdx); + } + }); + + signSlice.append('path') .attr('transform', `translate(${_cx},${_cy})`) .attr('d', arc({ innerRadius: R.signInner, @@ -677,14 +748,14 @@ const NatusWheel = (() => { const cr = _r * 0.065; const sf = (cr * 2 * 0.85) / 640; - sigGroup.append('circle') + signSlice.append('circle') .attr('cx', lx) .attr('cy', ly) .attr('r', cr) .attr('class', `nw-sign-icon-bg--${sign.element.toLowerCase()}`); if (_signPaths[sign.name]) { - sigGroup.append('path') + signSlice.append('path') .attr('d', _signPaths[sign.name]) .attr('transform', `translate(${lx},${ly}) scale(${sf}) translate(-320,-320)`) @@ -716,7 +787,17 @@ const NatusWheel = (() => { startAngle: sa + Math.PI / 2, endAngle: ea + Math.PI / 2, })) - .attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd'); + .attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd') + .attr('data-house', i + 1) + .on('click', function (event) { + event.stopPropagation(); + const clickIdx = i; // _houseItems[i].num === i+1 + if (_activeRing === 'houses' && _activeIdx === clickIdx) { + _closeTooltip(); + } else { + _activateHouse(clickIdx); + } + }); }); houses.forEach(({ i, startA, midA }) => { diff --git a/src/functional_tests/test_applet_my_sky.py b/src/functional_tests/test_applet_my_sky.py index b7618b6..99696b8 100644 --- a/src/functional_tests/test_applet_my_sky.py +++ b/src/functional_tests/test_applet_my_sky.py @@ -246,13 +246,13 @@ class MySkyAppletFormTest(FunctionalTest): const FIXTURE = """ + _json.dumps(_CHART_FIXTURE) + """; window._origFetch = window.fetch; window.fetch = function(url, opts) { - if (url.includes('/sky/preview/')) { + if (url.includes('/sky/preview')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(FIXTURE), }); } - if (url.includes('/sky/save/')) { + if (url.includes('/sky/save')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({saved: true}), diff --git a/src/static/tests/NatusWheelSpec.js b/src/static/tests/NatusWheelSpec.js index c426f09..8a9ebca 100644 --- a/src/static/tests/NatusWheelSpec.js +++ b/src/static/tests/NatusWheelSpec.js @@ -551,3 +551,271 @@ xdescribe("NatusWheel — half-wheel tooltip positioning", () => { expect(parseFloat(tooltipEl.style.left)).toBe(270); }); }); + +// ── T14 — element tooltip shows contributor planets (enriched data) ─────────── +// +// When element data arrives in enriched format {count, contributors/stellia/parades}, +// clicking a classic-element slice lists contributor planet names in the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — element tooltip contributor display", () => { + + const ENRICHED_CHART = { + planets: { + Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, + Venus: { sign: "Gemini", degree: 63.3, retrograde: false }, + Mars: { sign: "Leo", degree: 132.0, retrograde: false }, + Saturn: { sign: "Virgo", degree: 153.0, retrograde: false }, + }, + houses: { + cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150], + asc: 180.0, mc: 90.0, + }, + elements: { + Fire: { count: 2, contributors: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Mars", sign: "Leo" }, + ]}, + Stone: { count: 1, contributors: [ + { planet: "Saturn", sign: "Virgo" }, + ]}, + Air: { count: 0, contributors: [] }, + Water: { count: 0, contributors: [] }, + Time: { count: 1, stellia: [ + { sign: "Gemini", planets: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Venus", sign: "Gemini" }, + ]}, + ]}, + Space: { count: 1, parades: [ + { signs: ["Gemini", "Leo", "Virgo"], + planets: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Mars", sign: "Leo" }, + { planet: "Saturn", sign: "Virgo" }, + ]}, + ]}, + }, + aspects: [], + distinctions: { "1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":1,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, ENRICHED_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T14a — Fire slice lists contributor planet symbols ☉ (Sun) and ♂ (Mars) + it("T14a: clicking Fire element slice shows contributor planet symbols", () => { + const fireSlice = svgEl.querySelector("[data-element='Fire']"); + expect(fireSlice).not.toBeNull(); + fireSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("☉"); // Sun + expect(body).toContain("♂"); // Mars + }); + + // T14b — Space slice shows parade formation with planet symbols + it("T14b: clicking Space element slice shows parade formation block", () => { + const spaceSlice = svgEl.querySelector("[data-element='Space']"); + spaceSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("Parade"); + // Planet symbols for Sun ☉, Mars ♂, Saturn ♄ appear in the parade + expect(body).toContain("☉"); + }); + + // T14c — Time slice shows stellium formation with planet symbols + it("T14c: clicking Time element slice shows stellium formation block", () => { + const timeSlice = svgEl.querySelector("[data-element='Time']"); + timeSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("Stellium"); + // Sun ☉ and Venus ♀ are in the Gemini stellium + expect(body).toContain("☉"); + expect(body).toContain("♀"); + }); + + // T14d — Air slice (count 0) shows em dash fallback, not an empty list + it("T14d: empty element slice shows em dash fallback", () => { + const airSlice = svgEl.querySelector("[data-element='Air']"); + airSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("—"); + }); +}); + +// ── T12 — sign ring click tooltips ──────────────────────────────────────────── +// +// Clicking a sign slice shows: +// ♉ Taurus · Stone +// Clicking the same sign again closes the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — sign ring click tooltips", () => { + + const SIGN_CHART = { + planets: { + Sun: { sign: "Taurus", degree: 40.0, retrograde: false }, + }, + houses: { + cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + asc: 0, + mc: 270, + }, + elements: { Fire: 0, Stone: 1, Air: 0, Water: 0, Time: 0, Space: 0 }, + aspects: [], + distinctions: { "1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, SIGN_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T12a — clicking a sign shows symbol + name + element + it("T12a: clicking a sign slice shows sign symbol, name, and element", () => { + const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']"); + expect(taurusSlice).not.toBeNull(); + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(bodyText).toContain("Taurus"); + expect(bodyText).toContain("Stone"); + expect(bodyText).toContain("♉"); + }); + + // T12b — clicking same sign again closes the tooltip + it("T12b: clicking the same sign again hides the tooltip", () => { + const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']"); + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).not.toBe("none"); + + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).toBe("none"); + }); +}); + +// ── T13 — house ring click tooltips ─────────────────────────────────────────── +// +// Clicking a house slice shows: +// 3 · Education +// Clicking the same house again closes the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — house ring click tooltips", () => { + + const HOUSE_CHART = { + planets: { + Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, + }, + houses: { + cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + asc: 0, + mc: 270, + }, + elements: { Fire: 1, Stone: 0, Air: 0, Water: 0, Time: 0, Space: 0 }, + aspects: [], + distinctions: { "1":0,"2":0,"3":1,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, HOUSE_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T13a — clicking a house slice shows house number + label + it("T13a: clicking a house slice shows house number and house label", () => { + // House 3 is at index 2 (zero-based), cusps[2]=60° span + const house3 = svgEl.querySelector("[data-house='3']"); + expect(house3).not.toBeNull(); + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(bodyText).toContain("3"); + expect(bodyText).toContain("Education"); + }); + + // T13b — clicking same house again closes the tooltip + it("T13b: clicking the same house again hides the tooltip", () => { + const house3 = svgEl.querySelector("[data-house='3']"); + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).not.toBe("none"); + + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).toBe("none"); + }); +}); diff --git a/src/static_src/tests/NatusWheelSpec.js b/src/static_src/tests/NatusWheelSpec.js index c426f09..8a9ebca 100644 --- a/src/static_src/tests/NatusWheelSpec.js +++ b/src/static_src/tests/NatusWheelSpec.js @@ -551,3 +551,271 @@ xdescribe("NatusWheel — half-wheel tooltip positioning", () => { expect(parseFloat(tooltipEl.style.left)).toBe(270); }); }); + +// ── T14 — element tooltip shows contributor planets (enriched data) ─────────── +// +// When element data arrives in enriched format {count, contributors/stellia/parades}, +// clicking a classic-element slice lists contributor planet names in the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — element tooltip contributor display", () => { + + const ENRICHED_CHART = { + planets: { + Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, + Venus: { sign: "Gemini", degree: 63.3, retrograde: false }, + Mars: { sign: "Leo", degree: 132.0, retrograde: false }, + Saturn: { sign: "Virgo", degree: 153.0, retrograde: false }, + }, + houses: { + cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150], + asc: 180.0, mc: 90.0, + }, + elements: { + Fire: { count: 2, contributors: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Mars", sign: "Leo" }, + ]}, + Stone: { count: 1, contributors: [ + { planet: "Saturn", sign: "Virgo" }, + ]}, + Air: { count: 0, contributors: [] }, + Water: { count: 0, contributors: [] }, + Time: { count: 1, stellia: [ + { sign: "Gemini", planets: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Venus", sign: "Gemini" }, + ]}, + ]}, + Space: { count: 1, parades: [ + { signs: ["Gemini", "Leo", "Virgo"], + planets: [ + { planet: "Sun", sign: "Gemini" }, + { planet: "Mars", sign: "Leo" }, + { planet: "Saturn", sign: "Virgo" }, + ]}, + ]}, + }, + aspects: [], + distinctions: { "1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":1,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, ENRICHED_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T14a — Fire slice lists contributor planet symbols ☉ (Sun) and ♂ (Mars) + it("T14a: clicking Fire element slice shows contributor planet symbols", () => { + const fireSlice = svgEl.querySelector("[data-element='Fire']"); + expect(fireSlice).not.toBeNull(); + fireSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("☉"); // Sun + expect(body).toContain("♂"); // Mars + }); + + // T14b — Space slice shows parade formation with planet symbols + it("T14b: clicking Space element slice shows parade formation block", () => { + const spaceSlice = svgEl.querySelector("[data-element='Space']"); + spaceSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("Parade"); + // Planet symbols for Sun ☉, Mars ♂, Saturn ♄ appear in the parade + expect(body).toContain("☉"); + }); + + // T14c — Time slice shows stellium formation with planet symbols + it("T14c: clicking Time element slice shows stellium formation block", () => { + const timeSlice = svgEl.querySelector("[data-element='Time']"); + timeSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("Stellium"); + // Sun ☉ and Venus ♀ are in the Gemini stellium + expect(body).toContain("☉"); + expect(body).toContain("♀"); + }); + + // T14d — Air slice (count 0) shows em dash fallback, not an empty list + it("T14d: empty element slice shows em dash fallback", () => { + const airSlice = svgEl.querySelector("[data-element='Air']"); + airSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const body = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(body).toContain("—"); + }); +}); + +// ── T12 — sign ring click tooltips ──────────────────────────────────────────── +// +// Clicking a sign slice shows: +// ♉ Taurus · Stone +// Clicking the same sign again closes the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — sign ring click tooltips", () => { + + const SIGN_CHART = { + planets: { + Sun: { sign: "Taurus", degree: 40.0, retrograde: false }, + }, + houses: { + cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + asc: 0, + mc: 270, + }, + elements: { Fire: 0, Stone: 1, Air: 0, Water: 0, Time: 0, Space: 0 }, + aspects: [], + distinctions: { "1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, SIGN_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T12a — clicking a sign shows symbol + name + element + it("T12a: clicking a sign slice shows sign symbol, name, and element", () => { + const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']"); + expect(taurusSlice).not.toBeNull(); + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(bodyText).toContain("Taurus"); + expect(bodyText).toContain("Stone"); + expect(bodyText).toContain("♉"); + }); + + // T12b — clicking same sign again closes the tooltip + it("T12b: clicking the same sign again hides the tooltip", () => { + const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']"); + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).not.toBe("none"); + + taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).toBe("none"); + }); +}); + +// ── T13 — house ring click tooltips ─────────────────────────────────────────── +// +// Clicking a house slice shows: +// 3 · Education +// Clicking the same house again closes the tooltip. +// ───────────────────────────────────────────────────────────────────────────── + +describe("NatusWheel — house ring click tooltips", () => { + + const HOUSE_CHART = { + planets: { + Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, + }, + houses: { + cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + asc: 0, + mc: 270, + }, + elements: { Fire: 1, Stone: 0, Air: 0, Water: 0, Time: 0, Space: 0 }, + aspects: [], + distinctions: { "1":0,"2":0,"3":1,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0 }, + house_system: "O", + }; + + let svgEl, tooltipEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_natus_svg"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_natus_tooltip"; + tooltipEl.className = "tt"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + + NatusWheel.draw(svgEl, HOUSE_CHART); + }); + + afterEach(() => { + NatusWheel.clear(); + svgEl.remove(); + tooltipEl.remove(); + }); + + // T13a — clicking a house slice shows house number + label + it("T13a: clicking a house slice shows house number and house label", () => { + // House 3 is at index 2 (zero-based), cusps[2]=60° span + const house3 = svgEl.querySelector("[data-house='3']"); + expect(house3).not.toBeNull(); + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(tooltipEl.style.display).not.toBe("none"); + const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML; + expect(bodyText).toContain("3"); + expect(bodyText).toContain("Education"); + }); + + // T13b — clicking same house again closes the tooltip + it("T13b: clicking the same house again hides the tooltip", () => { + const house3 = svgEl.querySelector("[data-house='3']"); + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).not.toBe("none"); + + house3.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(tooltipEl.style.display).toBe("none"); + }); +}); diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html index 35092f8..b5782c5 100644 --- a/src/templates/apps/dashboard/_partials/_applet-my-sky.html +++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html @@ -85,9 +85,17 @@ {% if request.user.sky_chart_data %} - // Sky already saved — draw the stored wheel immediately. - const data = JSON.parse(document.getElementById('id_my_sky_data').textContent); - NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, data); }); + // Sky already saved — fetch fresh enriched data from server then draw. + fetch('{% url "sky_natus_data" %}') + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function (data) { + NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, data); }); + }) + .catch(function () { + // Fallback: draw from inline stale data if endpoint fails. + var stale = JSON.parse(document.getElementById('id_my_sky_data').textContent); + NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, stale); }); + }); {% else %}