diff --git a/content.py b/content.py index fc31e2d..844c6c4 100644 --- a/content.py +++ b/content.py @@ -69,6 +69,11 @@ _TEMP_METRICS = {"tmax", "tmin", "feels"} F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM", "BS", "BZ", "KY", "PW", "FM", "MH", "LR"}) +# One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same +# decision in frontend/units.js. +MM_PER_IN = 25.4 +KMH_PER_MPH = 1.609344 + # The unit the current page renders temperatures in. None = °F, and also means # "this page has no city", which is what keeps the interactive pages on the # client-side locale default instead of being pinned server-side. @@ -156,6 +161,38 @@ def _temp_text(f) -> str: return f"{_shown(f)}°{_letter()}" +def _precip(v): + """Precipitation as a client-convertible span, the same contract as _temp(): + text in the page's unit, data-precip-in always inches.""" + if v is None: + return "—" + return Markup('{}').format( + v, _precip_text(v)) + + +def _precip_text(v) -> str: + """Millimetres get one decimal and inches two — 0.04 in and 1.0 mm are the same + amount, so '1.02 mm' would advertise precision the source doesn't have.""" + if v is None: + return "—" + return f"{v * MM_PER_IN:.1f} mm" if _UNIT.get() == "C" else f"{v:.2f} in" + + +def _wind(v): + """Wind/gust as a client-convertible span; data-wind-mph is always mph.""" + if v is None: + return "—" + return Markup('{}').format( + v, _wind_text(v)) + + +def _wind_text(v) -> str: + if v is None: + return "—" + return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C" + else f"{_round_half_up(v)} mph") + + def _month_year(date_s: str) -> str: """'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to spend, so a record's date gives up its day.""" @@ -186,10 +223,12 @@ def _fmt(metric: str, v) -> str: if metric in _TEMP_METRICS: return _temp(v) if metric == "precip": - return f"{v:.2f} in" + return _precip(v) if metric == "humid": + # Absolute humidity is g/m³ in both systems — there is no imperial unit for + # it anyone would recognise — so it is never converted. return f"{v:.1f} g/m³" - return f"{round(v)} mph" # wind, gust + return _wind(v) # wind, gust # Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging @@ -390,7 +429,7 @@ def _monthly_normals(history) -> list[dict]: "low_f": low_f, "range_lo_f": rng_lo, "range_hi_f": rng_hi, - "precip": f"{precip['mean']:.2f} in" if precip else "—", + "precip": _precip(precip["mean"]) if precip else "—", "precip_v": precip["mean"] if precip else None, "bar": _range_bar(rng_lo, rng_hi), }) @@ -584,7 +623,7 @@ def _month_context(request, city, history, month_idx: int) -> dict: stats.append(("Average low", _temp(tmin["mean"]))) stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90']))) if precip: - stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in")) + stats.append(("Average daily precipitation", _precip(precip["mean"]))) # Record high and low for every metric in this calendar month (mirrors the # all-time records cards, but scoped to the month). Precip is special-cased: # a per-day "record low" is just zero, and the dry-streak helper would wrongly @@ -609,8 +648,8 @@ def _month_context(request, city, history, month_idx: int) -> dict: lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True) records.append({ "label": label, - "high": f"{hi['total']:.1f} in", "high_date": str(hi["yr"]), "high_tag": "Wettest", - "low": f"{lo['total']:.1f} in", "low_date": str(lo["yr"]), "low_tag": "Driest", + "high": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest", + "low": _precip(lo["total"]), "low_date": str(lo["yr"]), "low_tag": "Driest", }) else: records.append({ diff --git a/tests/test_content.py b/tests/test_content.py index 9311328..5b59e19 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -304,6 +304,74 @@ def test_units_default_to_the_citys_country(client): assert 'data-unit-default="F"' in us +_PRECIP_SPAN = re.compile(r'([\d.]+) (in|mm)') + + +def test_precip_and_wind_follow_the_citys_unit(client): + # One toggle drives every measure: a °C page shows mm and km/h, not °C mixed + # with inches and mph. The data-* attribute stays imperial in both, so + # climate.js converts from the same source of truth it does for temperature. + gb = client.get(f"{B}/climate/{SLUG}/records").text # London, GB + us = client.get(f"{B}/climate/seattle-washington-us/records").text + + gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us) + assert gb_p and us_p + + assert {u for _raw, _shown, u in gb_p} == {"mm"} + assert {u for _raw, _shown, u in us_p} == {"in"} + + # The conversion is real, not just a relabel. + for raw, shown, _u in gb_p: + assert abs(float(shown) - float(raw) * 25.4) < 0.05, (raw, shown) + # On an imperial page the attribute and the text agree. + for raw, shown, _u in us_p: + assert abs(float(shown) - float(raw)) < 0.005, (raw, shown) + + +def test_wind_and_humidity_render_per_unit_system(): + # The synthetic history carries only tmax/tmin/precip, so wind and humidity + # never reach a rendered page in tests — exercise the renderers directly + # rather than assert on a page that structurally cannot show them. + import content + with content._unit_scope("F"): + assert content._wind_text(10) == "10 mph" + assert '10 mph' == content._wind(10) + assert content._fmt("humid", 7.75) == "7.8 g/m³" + with content._unit_scope("C"): + # 10 mph -> 16.09 km/h, rounded half-up like JS. + assert content._wind_text(10) == "16 km/h" + # data-wind-mph stays imperial: it is what climate.js converts from. + assert 'data-wind-mph="10.0"' in content._wind(10) + # Absolute humidity has no imperial equivalent anyone would recognise, so + # it reads the same in both systems. + assert content._fmt("humid", 7.75) == "7.8 g/m³" + + +def test_precip_precision_differs_by_unit(): + # 0.04 in and 1.0 mm are the same amount; rendering "1.02 mm" would advertise + # precision the source doesn't have. + import content + with content._unit_scope("F"): + assert content._precip_text(0.04) == "0.04 in" + with content._unit_scope("C"): + assert content._precip_text(0.04) == "1.0 mm" + assert content._precip_text(1.81) == "46.0 mm" + + +def test_measure_conversion_constants_match_the_frontend(): + # Same drift risk as the country list: two hand-maintained copies. + import os + import content + units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + os.pardir, "frontend", "units.js") + with open(units_js, encoding="utf-8") as f: + src = f.read() + mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1)) + kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1)) + assert mm == content.MM_PER_IN + assert kmh == content.KMH_PER_MPH + + def test_unit_default_absent_without_a_city(client): # No city -> no attribute -> units.js keeps today's locale behaviour. Pinning # these to "F" would silently regress the interactive pages.