The button was dead on plain HTTP and failed silently everywhere.
Browsers only expose geolocation on secure origins, but `navigator.geolocation`
still EXISTS on http:// — getCurrentPosition just fails with a permission error
("Only secure origins are allowed"). The guard was `if (!navigator.geolocation)
return;`, which passes on http://, so the call went ahead, errored, and landed
in an empty error callback. Nothing happened at all.
`make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the
button could never work in the normal dev-and-phone workflow, and gave no hint
why. It would have worked on thermograph.org, which is HTTPS.
- Gate on `window.isSecureContext`, not just the API's presence. Where the
browser will refuse, hide the locate button and promote "Pick on the map" to
primary rather than offering a control that cannot work.
- Report failures: a declined prompt, a timeout, and an unavailable fix each get
their own message in an aria-live slot. A silent failure is indistinguishable
from a broken button, which is exactly how this went unnoticed.
- Show a "Locating…" state while the fix is pending; it can take seconds.
To exercise geolocation against the LAN dev server, serve it over TLS:
`make lan-run TLS=1` (self-signed cert into certs/).
Verified by driving the button in a real browser across all three cases:
insecure origin (button hidden, map promoted), permission granted (hero
re-points at the located place), permission denied (message shown, button
restored).
212 lines
8.2 KiB
Python
212 lines
8.2 KiB
Python
"""Tests for the homepage: the cache-only "unusual right now" precompute, the
|
|
server-rendered surfaces, and the digest signup.
|
|
|
|
The precompute must never touch the network — a sweep over ~1000 cities that
|
|
fetched would burst the archive quota — so the tests here assert that the
|
|
fetching climate helpers are not called at all.
|
|
"""
|
|
import datetime
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app as appmod
|
|
import cities
|
|
import climate
|
|
import content
|
|
import homepage
|
|
|
|
B = "/thermograph"
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
@pytest.fixture
|
|
def no_feed(monkeypatch):
|
|
"""The homepage with no precomputed feed at all."""
|
|
monkeypatch.setattr(homepage, "load", lambda: None)
|
|
return None
|
|
|
|
|
|
def _pick(name, pct, cls, grade, tail, slug=None):
|
|
return {
|
|
"slug": slug or (name.lower().replace(" ", "-") + "-xx"),
|
|
"name": name, "display": f"{name}, Somewhere",
|
|
"lat": 1.0, "lon": 2.0, "cell_id": "1_2",
|
|
"date": datetime.date.today().isoformat(),
|
|
"metric": "tmax", "metric_label": "High temp", "window_label": "mid-July",
|
|
"value": 99.0, "percentile": pct, "grade": grade, "cls": cls,
|
|
"departure": abs(pct - 50), "tail": tail,
|
|
}
|
|
|
|
|
|
# --- the precompute ----------------------------------------------------------
|
|
def test_build_never_fetches_upstream(monkeypatch):
|
|
"""The sweep uses the cache-only readers, never the fetching ones."""
|
|
def boom(*a, **k):
|
|
raise AssertionError("the homepage sweep must not fetch upstream")
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
feed = homepage.build(limit=20)
|
|
assert set(feed) >= {"generated_at", "date", "considered", "picks", "ranked"}
|
|
|
|
|
|
def test_build_grades_cached_cities(monkeypatch, history, recent):
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: recent.clone())
|
|
feed = homepage.build(limit=5)
|
|
assert feed["considered"] == 5
|
|
for row in feed["ranked"]:
|
|
assert row["percentile"] is not None
|
|
assert row["cls"] and not row["cls"].startswith("--") # token name, no prefix
|
|
assert row["tail"] in ("warm", "cold")
|
|
|
|
|
|
def test_missing_cell_cache_is_skipped_not_fatal(monkeypatch):
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None)
|
|
feed = homepage.build(limit=10)
|
|
assert feed["considered"] == 0 and feed["ranked"] == []
|
|
|
|
|
|
def test_load_survives_missing_and_corrupt_file(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "nope.json"))
|
|
assert homepage.load() is None
|
|
|
|
bad = tmp_path / "homepage.json"
|
|
bad.write_text("{not json")
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(bad))
|
|
assert homepage.load() is None
|
|
|
|
bad.write_text('{"unexpected": true}')
|
|
assert homepage.load() is None
|
|
|
|
|
|
def test_refresh_writes_atomically(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
homepage.refresh(limit=3)
|
|
written = json.loads((tmp_path / "homepage.json").read_text())
|
|
assert written["ranked"] == []
|
|
# No temp files left behind.
|
|
assert [p.name for p in tmp_path.iterdir()] == ["homepage.json"]
|
|
|
|
|
|
def test_staleness(monkeypatch):
|
|
today = datetime.date.today().isoformat()
|
|
assert not homepage.is_stale({"date": today, "generated_at": time.time()})
|
|
assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600})
|
|
assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()})
|
|
|
|
|
|
# --- the rendered page -------------------------------------------------------
|
|
def test_homepage_is_complete_without_js(client, no_feed):
|
|
"""A crawler (or a reader with JS off) gets the whole page as real HTML."""
|
|
r = client.get(f"{B}/")
|
|
assert r.status_code == 200
|
|
html = r.text
|
|
assert "How unusual is your weather?" in html
|
|
assert "How it works" in html
|
|
assert "Climate context for 1,000 cities" in html
|
|
assert "Free. No ads. No tracking." in html
|
|
# The interactive tool's DOM contract survives the move to Jinja.
|
|
for dom_id in ('id="find-btn"', 'id="date-input"', 'id="panel"',
|
|
'id="placeholder"', 'id="results"'):
|
|
assert dom_id in html
|
|
# Exactly one h1: the hero headline. The brand degrades to a <p>.
|
|
assert html.count("<h1") == 1
|
|
assert 'p class="site-name"' in html
|
|
|
|
|
|
def test_hero_locate_has_a_status_slot(client, no_feed):
|
|
"""The locate button needs somewhere to report a declined prompt or a
|
|
timeout. Without it the control fails silently, which is how it shipped
|
|
broken: navigator.geolocation exists on plain http:// but always errors."""
|
|
html = client.get(f"{B}/").text
|
|
assert 'id="hero-locate"' in html
|
|
assert 'id="hero-locate-msg"' in html
|
|
assert 'aria-live="polite"' in html
|
|
|
|
|
|
def test_city_chips_all_resolve(client, no_feed):
|
|
"""Every homepage chip must be a real, routable city — a stale slug would
|
|
render an empty chip list and leak a 404 link."""
|
|
resolved = content._home_cities()
|
|
assert len(resolved) == len(content.HOME_CITY_SLUGS)
|
|
html = client.get(f"{B}/").text
|
|
for city in resolved:
|
|
assert f"/climate/{city['slug']}" in html
|
|
assert cities.get(city["slug"]) is not None
|
|
|
|
|
|
def test_records_strip_shows_both_tails(client, monkeypatch):
|
|
"""Two-sided honesty: a qualifying cold-tail city appears alongside the heat."""
|
|
feed = {
|
|
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
|
"considered": 800,
|
|
"picks": {"extreme": _pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm")},
|
|
"ranked": [_pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm"),
|
|
_pick("Ushuaia", 2.1, "very-cold", "Very Low", "cold")],
|
|
}
|
|
monkeypatch.setattr(homepage, "load", lambda: feed)
|
|
html = client.get(f"{B}/").text
|
|
assert "Unusual right now" in html
|
|
assert "Phoenix" in html and "Ushuaia" in html
|
|
# Band colour comes from the shared tokens, and the band word is always
|
|
# present too — colour is never the only signal.
|
|
assert "var(--rec-hot)" in html and "var(--very-cold)" in html
|
|
assert "Near Record" in html and "Very Low" in html
|
|
|
|
|
|
def test_hero_uses_most_unusual_default(client, monkeypatch):
|
|
feed = {
|
|
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
|
"considered": 800,
|
|
"picks": {"extreme": _pick("Phoenix", 96.0, "very-hot", "Very High", "warm")},
|
|
"ranked": [],
|
|
}
|
|
monkeypatch.setattr(homepage, "load", lambda: feed)
|
|
html = client.get(f"{B}/").text
|
|
assert "Today in Phoenix, Somewhere" in html
|
|
assert "96th percentile" in html
|
|
assert "the most unusual weather we're tracking" in html
|
|
|
|
|
|
def test_homepage_renders_without_feed(client, no_feed):
|
|
"""A cold checkout with no precomputed feed still serves a complete page."""
|
|
html = client.get(f"{B}/").text
|
|
assert "How unusual is your weather?" in html
|
|
assert "Unusual right now" not in html # strip is omitted, not broken
|
|
assert 'id="hero-grade"' in html # the frame is still there
|
|
|
|
|
|
def test_homepage_etag_revalidates(client, no_feed):
|
|
r = client.get(f"{B}/")
|
|
etag = r.headers["etag"]
|
|
again = client.get(f"{B}/", headers={"If-None-Match": etag})
|
|
assert again.status_code == 304
|
|
|
|
|
|
def test_old_static_index_is_gone(client):
|
|
"""index.html must not linger behind the static mount as indexable duplicate
|
|
content now that / is server-rendered."""
|
|
assert client.get(f"{B}/index.html").status_code == 404
|
|
|
|
|
|
def test_privacy_page(client):
|
|
r = client.get(f"{B}/privacy")
|
|
assert r.status_code == 200
|
|
assert "no analytics library" in r.text.lower() or "no analytics" in r.text.lower()
|
|
assert "Use my location" in r.text
|
|
|
|
|
|
def test_privacy_is_linked_but_not_in_sitemap(client):
|
|
assert f"{B}/privacy" in client.get(f"{B}/").text
|
|
# It's a policy page, not a crawl target we want ranked.
|
|
assert "/privacy" not in client.get(f"{B}/sitemap.xml").text
|