some FT renames for readability; added natus form to My Sky applet
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

This commit is contained in:
Disco DeDisco
2026-04-17 22:30:11 -04:00
parent 8a24021739
commit 7c03bded8d
8 changed files with 441 additions and 5 deletions

View File

@@ -5,6 +5,8 @@ natus (natal chart) interface where the user can save their personal sky
to their account (stored on the User model, independent of any game room). to their account (stored on the User model, independent of any game room).
""" """
import json as _json
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -198,3 +200,102 @@ class MySkyAppletWheelTest(FunctionalTest):
.value_of_css_property("display"), .value_of_css_property("display"),
"block", "block",
)) ))
class MySkyAppletFormTest(FunctionalTest):
"""My Sky applet shows natus entry form when no sky data is saved."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
# ── T4 ───────────────────────────────────────────────────────────────────
def test_applet_shows_entry_form_when_no_sky_saved(self):
"""When no sky data is saved the My Sky applet shows all natus form
fields and a disabled SAVE SKY button; no wheel is drawn yet."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
applet = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_sky")
)
applet.find_element(By.ID, "id_nf_date")
applet.find_element(By.ID, "id_nf_time")
applet.find_element(By.ID, "id_nf_place")
applet.find_element(By.ID, "id_nf_lat")
applet.find_element(By.ID, "id_nf_lon")
applet.find_element(By.ID, "id_nf_tz")
applet.find_element(By.ID, "id_natus_confirm")
self.assertFalse(applet.find_elements(By.CSS_SELECTOR, ".nw-root"))
# ── T5 ───────────────────────────────────────────────────────────────────
def test_applet_form_disappears_and_wheel_draws_after_save(self):
"""Filling the applet form and clicking SAVE SKY hides the form wrap
and draws the natal wheel in its place."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap")
)
# Mock fetch: preview → chart fixture; save → {saved: true}
self.browser.execute_script("""
const FIXTURE = """ + _json.dumps(_CHART_FIXTURE) + """;
window._origFetch = window.fetch;
window.fetch = function(url, opts) {
if (url.includes('/sky/preview/')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(FIXTURE),
});
}
if (url.includes('/sky/save/')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({saved: true}),
});
}
return window._origFetch(url, opts);
};
""")
# Fill required fields and fire input to trigger schedulePreview
self.browser.execute_script("""
document.getElementById('id_nf_date').value = '1990-06-15';
document.getElementById('id_nf_lat').value = '51.5074';
document.getElementById('id_nf_lon').value = '-0.1278';
document.getElementById('id_nf_tz').value = 'Europe/London';
document.getElementById('id_nf_date').dispatchEvent(
new Event('input', {bubbles: true})
);
""")
# Wait for confirm button to be enabled (preview resolved)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(
confirm_btn.get_attribute("disabled")
))
confirm_btn.click()
# Form wrap should become hidden
form_wrap = self.browser.find_element(By.ID, "id_applet_sky_form_wrap")
self.wait_for(lambda: self.assertEqual(
form_wrap.value_of_css_property("display"), "none"
))
# Natal wheel should be drawn inside the applet
self.wait_for(lambda: self.assertTrue(
self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_my_sky .nw-root"
)
))

View File

@@ -522,6 +522,21 @@ body[class*="-light"] #id_natus_tooltip {
max-height: none; max-height: none;
align-self: center; align-self: center;
} }
#id_applet_sky_form_wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
#id_natus_confirm {
flex-shrink: 0;
width: 100%;
}
}
} }
// ── Sky full page (aperture + column layout) ────────────────────────────────── // ── Sky full page (aperture + column layout) ──────────────────────────────────

View File

@@ -1,25 +1,345 @@
{% load static %} {% load static %}
<section <section
id="id_applet_my_sky" id="id_applet_my_sky"
data-preview-url="{% url 'sky_preview' %}"
data-save-url="{% url 'sky_save' %}"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2><a href="{% url 'sky' %}">My Sky</a></h2> <h2><a href="{% url 'sky' %}">My Sky</a></h2>
{% if not request.user.sky_chart_data %}
<div id="id_applet_sky_form_wrap">
<form id="id_natus_form" autocomplete="off">
<div class="natus-field">
<label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required>
</div>
<div class="natus-field">
<label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00">
<small>Local time at birth place. Use 12:00 if unknown.</small>
</div>
<div class="natus-field natus-place-field">
<label for="id_nf_place">Birth place</label>
<div class="natus-place-wrap">
<input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…"
autocomplete="off">
<button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm"
title="Use device location">
<i class="fa-solid fa-location-crosshairs"></i>
</button>
</div>
<div id="id_nf_suggestions" class="natus-suggestions" hidden></div>
</div>
<div class="natus-field natus-coords">
<div>
<label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1">
</div>
<div>
<label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1">
</div>
</div>
<div class="natus-field">
<label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from location">
<small id="id_nf_tz_hint"></small>
</div>
</form>
<div id="id_natus_status" class="natus-status"></div>
<button type="button" id="id_natus_confirm" class="btn btn-primary" disabled>
Save Sky
</button>
</div>
{% endif %}
<svg id="id_my_sky_svg" class="natus-svg"
{% if not request.user.sky_chart_data %}style="display:none;"{% endif %}></svg>
{% if request.user.sky_chart_data %} {% if request.user.sky_chart_data %}
<svg id="id_my_sky_svg" class="natus-svg"></svg>
{{ request.user.sky_chart_data|json_script:"id_my_sky_data" }} {{ request.user.sky_chart_data|json_script:"id_my_sky_data" }}
{% endif %} {% endif %}
</section> </section>
{% if request.user.sky_chart_data %}
<div id="id_natus_tooltip" class="tt" style="display:none;"></div> <div id="id_natus_tooltip" class="tt" style="display:none;"></div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script> <script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script> <script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
const data = JSON.parse(document.getElementById('id_my_sky_data').textContent);
const svgEl = document.getElementById('id_my_sky_svg'); const section = document.getElementById('id_applet_my_sky');
const svgEl = document.getElementById('id_my_sky_svg');
{% 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); }); NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, data); });
{% else %}
// No sky saved yet — wire up the entry form.
const formWrap = document.getElementById('id_applet_sky_form_wrap');
const form = document.getElementById('id_natus_form');
const statusEl = document.getElementById('id_natus_status');
const confirmBtn = document.getElementById('id_natus_confirm');
const geoBtn = document.getElementById('id_nf_geolocate');
const placeInput = document.getElementById('id_nf_place');
const latInput = document.getElementById('id_nf_lat');
const lonInput = document.getElementById('id_nf_lon');
const tzInput = document.getElementById('id_nf_tz');
const tzHint = document.getElementById('id_nf_tz_hint');
const suggestions = document.getElementById('id_nf_suggestions');
const PREVIEW_URL = section.dataset.previewUrl;
const SAVE_URL = section.dataset.saveUrl;
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)';
const LS_KEY = 'natus-form:dashboard:sky';
let _lastChartData = null;
let _placeDebounce = null;
let _chartDebounce = null;
const PLACE_DELAY = 400;
const CHART_DELAY = 300;
NatusWheel.preload();
// ── localStorage persistence ──────────────────────────────────────────────
function _saveForm() {
const d = {
date: document.getElementById('id_nf_date').value,
time: document.getElementById('id_nf_time').value,
place: placeInput.value,
lat: latInput.value,
lon: lonInput.value,
tz: tzInput.value,
};
try { localStorage.setItem(LS_KEY, JSON.stringify(d)); } catch (_) {}
}
function _restoreForm() {
let d;
try { d = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (_) {}
if (!d) return;
if (d.date) document.getElementById('id_nf_date').value = d.date;
if (d.time) document.getElementById('id_nf_time').value = d.time;
if (d.place) placeInput.value = d.place;
if (d.lat) latInput.value = d.lat;
if (d.lon) lonInput.value = d.lon;
if (d.tz) { tzInput.value = d.tz; tzHint.textContent = 'Auto-detected from coordinates.'; }
if (_formReady()) schedulePreview();
}
// ── Status ────────────────────────────────────────────────────────────────
function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'natus-status' + (type ? ` natus-status--${type}` : '');
}
// ── Nominatim place search ────────────────────────────────────────────────
placeInput.addEventListener('input', () => {
clearTimeout(_placeDebounce);
const q = placeInput.value.trim();
if (q.length < 3) { hideSuggestions(); return; }
_placeDebounce = setTimeout(() => fetchPlaces(q), PLACE_DELAY);
});
placeInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideSuggestions();
});
document.addEventListener('click', (e) => {
if (!placeInput.contains(e.target) && !suggestions.contains(e.target)) {
hideSuggestions();
}
});
function fetchPlaces(query) {
fetch(`${NOMINATIM}?format=json&q=${encodeURIComponent(query)}&limit=6`, {
headers: { 'User-Agent': USER_AGENT },
})
.then(r => r.json())
.then(results => {
if (!results.length) { hideSuggestions(); return; }
renderSuggestions(results);
})
.catch(() => hideSuggestions());
}
function renderSuggestions(results) {
suggestions.innerHTML = '';
results.forEach(place => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'natus-suggestion-item';
item.textContent = place.display_name;
item.addEventListener('click', () => selectPlace(place));
suggestions.appendChild(item);
});
suggestions.hidden = false;
}
function hideSuggestions() {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
function selectPlace(place) {
placeInput.value = place.display_name;
latInput.value = parseFloat(place.lat).toFixed(4);
lonInput.value = parseFloat(place.lon).toFixed(4);
hideSuggestions();
_saveForm();
schedulePreview();
}
// ── Geolocation ───────────────────────────────────────────────────────────
geoBtn.addEventListener('click', () => {
if (!navigator.geolocation) {
setStatus('Geolocation not supported by this browser.', 'error');
return;
}
setStatus('Requesting device location…');
navigator.geolocation.getCurrentPosition(
(pos) => {
latInput.value = pos.coords.latitude.toFixed(4);
lonInput.value = pos.coords.longitude.toFixed(4);
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latInput.value}&lon=${lonInput.value}`,
{ headers: { 'User-Agent': USER_AGENT } }
)
.then(r => r.json())
.then(d => { placeInput.value = _cityName(d.address) || d.display_name || ''; })
.catch(() => {})
.finally(() => _saveForm());
setStatus('');
schedulePreview();
},
() => setStatus('Location access denied.', 'error'),
);
});
function _cityName(addr) {
if (!addr) return '';
const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || '';
const region = addr.state || addr.county || addr.state_district || '';
const country = addr.country || '';
return [city, region, country].filter(Boolean).join(', ');
}
// ── Debounced preview (fetches chart data; does not draw) ─────────────────
form.addEventListener('input', (e) => {
if (e.target === placeInput) return;
_saveForm();
clearTimeout(_chartDebounce);
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
});
function _formReady() {
return document.getElementById('id_nf_date').value &&
latInput.value && lonInput.value;
}
function schedulePreview() {
if (!_formReady()) return;
const date = document.getElementById('id_nf_date').value;
const time = document.getElementById('id_nf_time').value || '12:00';
const lat = latInput.value;
const lon = lonInput.value;
const tz = tzInput.value.trim();
const params = new URLSearchParams({ date, time, lat, lon });
if (tz) params.set('tz', tz);
setStatus('Calculating…');
confirmBtn.disabled = true;
fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(d => {
_lastChartData = d;
if (!tzInput.value && d.timezone) {
tzInput.value = d.timezone;
tzHint.textContent = 'Auto-detected from coordinates.';
}
setStatus('');
confirmBtn.disabled = false;
})
.catch(err => {
setStatus(`Could not fetch chart: ${err.message}`, 'error');
confirmBtn.disabled = true;
});
}
// ── Save → hide form, draw wheel ──────────────────────────────────────────
confirmBtn.addEventListener('click', () => {
if (!_lastChartData) return;
confirmBtn.disabled = true;
setStatus('Saving…');
const payload = {
birth_dt: `${document.getElementById('id_nf_date').value}T${document.getElementById('id_nf_time').value || '12:00'}:00`,
birth_lat: parseFloat(latInput.value),
birth_lon: parseFloat(lonInput.value),
birth_place: placeInput.value,
house_system: _lastChartData.house_system || 'O',
chart_data: _lastChartData,
};
fetch(SAVE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _getCsrf() },
body: JSON.stringify(payload),
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(() => {
formWrap.style.display = 'none';
svgEl.style.display = '';
NatusWheel.preload().then(() => NatusWheel.draw(svgEl, _lastChartData));
})
.catch(err => {
setStatus(`Save failed: ${err.message}`, 'error');
confirmBtn.disabled = false;
});
});
// ── CSRF ──────────────────────────────────────────────────────────────────
function _getCsrf() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
_restoreForm();
{% endif %}
})(); })();
</script> </script>
{% endif %}