feat: legibility wave — veil conditions, first-run hints, kinder errors
Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors): - GET /api/conditions (new backend/app/routes/conditions.py): composes celestial veil_thinness with the cached NOAA Kp reading; lenient lon parsing (junk degrades to moon-only, never 422); geomagnetic may be null on a cold cache. Route tests stub the cache — no live NOAA calls. - VeilConditions strip in the séance side column: moon glyph + phase, % lit, veil-thinness phrase, Kp line only when data exists. Polls every 10 min; renders nothing while loading; no error state. - ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed once the mode's sensor runs this session, dismissal persisted in localStorage (qm_hint_<mode>). One mount line per panel. - Error copy upgraded to detect-and-redirect: mic denied points at site settings and the ouija board/wire; WebUSB-unsupported suggests EVP. - i18n en/es parity for every new string; coverage-check template domains extended for the new template-key call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.db import Base, async_session_maker, engine
|
||||
from app.routes.auth import router as auth_router
|
||||
from app.routes.codex import router as codex_router
|
||||
from app.routes.conditions import router as conditions_router
|
||||
from app.routes.device import router as device_router
|
||||
from app.routes.inventory import router as inventory_router
|
||||
from app.routes.shop import router as shop_router
|
||||
@@ -95,6 +96,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(codex_router)
|
||||
app.include_router(conditions_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(shop_router)
|
||||
|
||||
46
backend/app/routes/conditions.py
Normal file
46
backend/app/routes/conditions.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Veil conditions readout — the sky and the field, in one call.
|
||||
|
||||
Composes the two real-world signals the app already computes:
|
||||
|
||||
sky - app.celestial.veil_thinness(): moon phase/illumination and
|
||||
(when a longitude is offered) proximity to true solar
|
||||
midnight. Pure math, always present.
|
||||
geomagnetic - app.geomagnetic.geomagnetic_cache.reading(): NOAA's
|
||||
planetary Kp from cache. May be None on a cold cache or
|
||||
when NOAA is unreachable — that is a valid answer, not an
|
||||
error, and the frontend simply shows fewer lines.
|
||||
|
||||
`lon` is accepted as a raw string and parsed by hand so a malformed or
|
||||
out-of-range value degrades to the moon-only reading instead of a 422 —
|
||||
the conditions strip must never fail because a browser sent junk.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.celestial import veil_thinness
|
||||
from app.geomagnetic import geomagnetic_cache
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["conditions"])
|
||||
|
||||
|
||||
def _parse_longitude(raw: str | None) -> float | None:
|
||||
"""A usable longitude in [-180, 180], or None for anything else."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
lon = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(lon) or not -180.0 <= lon <= 180.0:
|
||||
return None
|
||||
return lon
|
||||
|
||||
|
||||
@router.get("/conditions")
|
||||
async def get_conditions(lon: str | None = None):
|
||||
return {
|
||||
"sky": veil_thinness(_parse_longitude(lon)),
|
||||
"geomagnetic": await geomagnetic_cache.reading(),
|
||||
}
|
||||
73
backend/tests/test_conditions.py
Normal file
73
backend/tests/test_conditions.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""GET /api/conditions — sky always present, geomagnetic nullable, lon lenient.
|
||||
|
||||
The geomagnetic cache is stubbed in every test so nothing here ever
|
||||
touches NOAA; a cold real cache would otherwise fire a live HTTP request.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import app.routes.conditions as conditions_module
|
||||
|
||||
|
||||
class _StubCache:
|
||||
def __init__(self, reading):
|
||||
self._reading = reading
|
||||
|
||||
async def reading(self):
|
||||
return self._reading
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def geo_null(monkeypatch):
|
||||
monkeypatch.setattr(conditions_module, "geomagnetic_cache", _StubCache(None))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def geo_storm(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
conditions_module,
|
||||
"geomagnetic_cache",
|
||||
_StubCache({"kp": 6.33, "label": "moderate geomagnetic storm",
|
||||
"disturbance": 6.33 / 9.0, "storm": True}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sky_always_present_geomagnetic_null_tolerated(client, geo_null):
|
||||
resp = await client.get("/api/conditions")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["geomagnetic"] is None
|
||||
sky = body["sky"]
|
||||
assert sky["moon_name"]
|
||||
assert 0.0 <= sky["moon_illumination"] <= 1.0
|
||||
assert 0.0 <= sky["thinness"] <= 1.0
|
||||
# No longitude offered — witching proximity is unknowable.
|
||||
assert sky["witching_proximity"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_lon_yields_witching_proximity(client, geo_null):
|
||||
resp = await client.get("/api/conditions", params={"lon": "-3.7"})
|
||||
assert resp.status_code == 200
|
||||
witching = resp.json()["sky"]["witching_proximity"]
|
||||
assert witching is not None
|
||||
assert 0.0 <= witching <= 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bad", ["ghost", "", "nan", "inf", "999", "-181"])
|
||||
async def test_invalid_lon_ignored_not_422(client, geo_null, bad):
|
||||
resp = await client.get("/api/conditions", params={"lon": bad})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sky"]["witching_proximity"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_geomagnetic_reading_passed_through(client, geo_storm):
|
||||
resp = await client.get("/api/conditions")
|
||||
assert resp.status_code == 200
|
||||
geo = resp.json()["geomagnetic"]
|
||||
assert geo["kp"] == pytest.approx(6.33)
|
||||
assert geo["storm"] is True
|
||||
assert geo["label"] == "moderate geomagnetic storm"
|
||||
Reference in New Issue
Block a user