From 48e1a59fd091abdc409d7c30f5e33bdf53ea7cbd Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 18 Oct 2026 14:28:34 +0200 Subject: feat(weather): parse an Open-Meteo cache instead of OWM JSON WMO codes to glyphs and words, moon phase, UV and AQI roles, and a parser for the tab-separated cache the fetcher will write. Groundwork for the Mirage-derived forecast cards. Co-Authored-By: Claude Sonnet 5.5 --- test/test_weather.lua | 358 +++++++++++++++++++++++--------------------------- 1 file changed, 161 insertions(+), 197 deletions(-) (limited to 'test/test_weather.lua') diff --git a/test/test_weather.lua b/test/test_weather.lua index cdcdc2d..cea4645 100644 --- a/test/test_weather.lua +++ b/test/test_weather.lua @@ -1,236 +1,200 @@ -- Checks for lib/weather.lua. -- Run from the repo root: lua test/test_weather.lua -- The domain tables are what silently misreport when a boundary is off by one, --- so every range boundary gets an assertion. The card itself is verified by --- screenshot. +-- so every range boundary gets an assertion. The cards themselves are verified +-- by rendering them. package.path = './?.lua;' .. package.path local weather = require 'lib.weather' --- === Condition id to icon ================================================= --- OWM ids group by hundreds, but the boundaries are not round numbers: the --- ranges come from the polybar script that ran against this API for years. --- Each assertion pairs the last id in a range with the first id of the next, --- which is where an off-by-one would hide. --- Sunrise 1500, sunset 1900, so DAY must sit INSIDE that window and NIGHT --- outside it. Naming them without checking them against the window is how the --- first draft of this test made every timestamp evaluate as night. -local DAY, NIGHT = 1700, 2000 - -local function icon(id, now) - return weather.icon(id, now, 1500, 1900) -- sunrise 1500, sunset 1900 +local function slurp(path) + local f = assert(io.open(path, 'r')) + local s = f:read('*a') + f:close() + return s end --- Thunderstorm: everything up to 232. -assert(icon(200, DAY) == weather.ICON.thunder_day, 'id 200 day') -assert(icon(232, DAY) == weather.ICON.thunder_day, 'id 232 is still thunder') -assert(icon(232, NIGHT) == weather.ICON.thunder_night, 'id 232 night') - --- Light drizzle: 233..311. -assert(icon(233, DAY) == weather.ICON.drizzle_day, 'id 233 leaves thunder') -assert(icon(311, DAY) == weather.ICON.drizzle_day, 'id 311 is still light drizzle') - --- Heavy drizzle: 312..321. -assert(icon(312, DAY) == weather.ICON.drizzle_heavy_day, 'id 312 is heavy drizzle') -assert(icon(321, DAY) == weather.ICON.drizzle_heavy_day, 'id 321 is still heavy drizzle') - --- Rain: 322..531. -assert(icon(322, DAY) == weather.ICON.rain_day, 'id 322 is rain') -assert(icon(531, DAY) == weather.ICON.rain_day, 'id 531 is still rain') - --- Snow: 532..622. One icon, no day/night variant. -assert(icon(600, DAY) == weather.ICON.snow, 'id 600 is snow') -assert(icon(622, NIGHT) == weather.ICON.snow, 'snow is the same at night') - --- Fog: 623..771. -assert(icon(741, DAY) == weather.ICON.fog, 'id 741 is fog') -assert(icon(771, DAY) == weather.ICON.fog, 'id 771 is still fog') - --- Tornado is a single id, not a range. -assert(icon(781, DAY) == weather.ICON.tornado, 'id 781 is tornado') - --- Clear and few clouds each have a day and a night face. -assert(icon(800, DAY) == weather.ICON.clear_day, 'id 800 day is the sun') -assert(icon(800, NIGHT) == weather.ICON.clear_night, 'id 800 night is the moon') -assert(icon(801, DAY) == weather.ICON.few_day, 'id 801 day') -assert(icon(801, NIGHT) == weather.ICON.few_night, 'id 801 night') - --- Overcast: 802..804, no night variant. -assert(icon(804, DAY) == weather.ICON.overcast, 'id 804 is overcast') - --- Anything outside the known ids must be visibly wrong, not silently sunny. -assert(icon(999, DAY) == weather.ICON.unknown, 'unknown id gets the error glyph') -assert(weather.icon(nil, DAY, 1500, 1900) == weather.ICON.unknown, 'nil id') - --- Nothing may raise. A missing timestamp with a known sun, or a known --- timestamp with no sun, both have to return a glyph rather than an error, --- because an error here is a blank dashboard with no message. -assert(weather.icon(800, nil, 1500, 1900) == weather.ICON.clear_day, - 'a nil now must not raise, and falls back to the day face') -assert(weather.icon(800, DAY, nil, nil) == weather.ICON.clear_day, - 'no sun times must not raise') - --- The codepoint VALUES are deliberately not asserted. A test comparing --- M.ICON.snow against a literal would only prove the same escape was typed --- twice; whether E31A actually draws a snow cloud in the target font is not --- something an assertion can see. That check is the glyph sheet rendered in --- the plan's Step 5, and it is how a wrong-but-present codepoint was caught --- before this file existed. +-- === WMO code to glyph ==================================================== +-- Open-Meteo reports WMO weather codes. The groups are sparse (0-3, 45, 48, +-- 51-57, ...), so each group's first and last code is asserted, plus a code +-- in a gap, which must read as unknown rather than silently sunny. +local I = weather.ICON +assert(weather.icon(0, true) == I.clear_day, 'code 0 day is the sun') +assert(weather.icon(0, false) == I.clear_night, 'code 0 night is the moon') +assert(weather.icon(1, true) == I.few_day, 'code 1 is few clouds') +assert(weather.icon(2, false) == I.few_night, 'code 2 night') +assert(weather.icon(3, false) == I.overcast, 'code 3 is overcast, no night face') +assert(weather.icon(45, true) == I.fog, 'code 45 is fog') +assert(weather.icon(48, true) == I.fog, 'code 48 is fog') +assert(weather.icon(51, true) == I.drizzle_day, 'code 51 is drizzle') +assert(weather.icon(57, false) == I.drizzle_night, 'code 57 night') +assert(weather.icon(61, true) == I.rain_day, 'code 61 is rain') +assert(weather.icon(67, true) == I.rain_day, 'code 67 is still rain') +assert(weather.icon(80, false) == I.rain_night, 'code 80 showers are rain') +assert(weather.icon(82, true) == I.rain_day, 'code 82 is rain') +assert(weather.icon(71, true) == I.snow, 'code 71 is snow') +assert(weather.icon(77, false) == I.snow, 'code 77 is snow at night too') +assert(weather.icon(85, true) == I.snow, 'code 85 snow showers') +assert(weather.icon(86, true) == I.snow, 'code 86 snow showers') +assert(weather.icon(95, true) == I.thunder_day, 'code 95 is thunder') +assert(weather.icon(99, false) == I.thunder_night, 'code 99 night') + +-- Gaps and junk. +assert(weather.icon(4, true) == I.unknown, 'code 4 is not a WMO code') +assert(weather.icon(50, true) == I.unknown, 'code 50 sits in a gap') +assert(weather.icon(-1, true) == I.unknown, 'negative code') +assert(weather.icon(nil, true) == I.unknown, 'nil code') +assert(weather.icon('3', true) == I.unknown, 'string code') + +-- An unknown day/night falls back to the day face, matching is_day(). +assert(weather.icon(0, nil) == I.clear_day, 'nil is_day draws the day face') + +-- The codepoint VALUES are deliberately not asserted. Whether a codepoint +-- draws a snow cloud in the target font is not something an assertion can +-- see; the glyph sheet in Task 3 is that check. + +-- === Descriptions ========================================================= +assert(weather.describe(0) == 'Clear sky', 'code 0 words') +assert(weather.describe(63) == 'Rain', 'code 63 words') +assert(weather.describe(99) == 'Thunderstorm, hail', 'code 99 words') +assert(weather.describe(4) == 'Unknown', 'gap code words') +assert(weather.describe(nil) == 'Unknown', 'nil code words') + +-- === Formatting =========================================================== +assert(weather.deg(13.2) == '13\u{00B0}', 'rounds down') +assert(weather.deg(13.5) == '14\u{00B0}', 'rounds half up') +assert(weather.deg(-0.4) == '0\u{00B0}', 'just below zero reads 0') +assert(weather.deg(-2.6) == '-3\u{00B0}', 'negative rounds away from zero') +assert(weather.deg(nil) == '--', 'missing temperature is --, never 0') + +assert(weather.num(77) == '77', 'plain number') +assert(weather.num(39.6, '%') == '40%', 'rounded with suffix') +assert(weather.num(0, '%') == '0%', 'zero is a real reading') +assert(weather.num(nil, '%') == '--', 'missing number is --') + +assert(weather.wind_unit('metric') == 'km/h', 'metric wind') +assert(weather.wind_unit('imperial') == 'mph', 'imperial wind') +assert(weather.wind_unit(nil) == 'km/h', 'unknown units default to metric') + +assert(weather.duration(37692) == '10h 28m', 'day length') +assert(weather.duration(0) == '0h 00m', 'zero length') +assert(weather.duration(-5) == '--', 'negative length is bad data') +assert(weather.duration(nil) == '--', 'missing length') + +-- === Thresholds =========================================================== +-- Each boundary and the value just under it. +assert(weather.uv_role(2.9) == 'ok', 'UV under 3 is ok') +assert(weather.uv_role(3) == 'warning', 'UV 3 is warning') +assert(weather.uv_role(7.9) == 'warning', 'UV under 8 is warning') +assert(weather.uv_role(8) == 'critical', 'UV 8 is critical') +assert(weather.uv_role(nil) == nil, 'missing UV has no role') + +assert(weather.aqi_role(40) == 'ok', 'AQI 40 is ok') +assert(weather.aqi_role(41) == 'warning', 'AQI 41 is warning') +assert(weather.aqi_role(80) == 'warning', 'AQI 80 is warning') +assert(weather.aqi_role(81) == 'critical', 'AQI 81 is critical') +assert(weather.aqi_role(nil) == nil, 'missing AQI has no role') + +-- === Moon ================================================================= +-- Known 2024 phases (UTC): new 01-11 11:57, full 01-25 17:54, new 02-09 22:59. +-- The mean synodic month drifts from the true one by up to about half a day, +-- so the tolerance is one day of phase. +local DAY_PHASE = 1 / 29.530588853 +local function circ(a, b) local d = math.abs(a - b) % 1; return math.min(d, 1 - d) end +local p_new = weather.moon(1704974220) +local p_full, lit_full = weather.moon(1706205240) +local p_new2, lit_new2 = weather.moon(1707519540) +assert(circ(p_new, 0) < DAY_PHASE, 'new moon phase near 0, got ' .. p_new) +assert(circ(p_full, 0.5) < DAY_PHASE, 'full moon phase near 0.5, got ' .. p_full) +assert(circ(p_new2, 0) < DAY_PHASE, 'next new moon near 0, got ' .. p_new2) +assert(lit_full > 0.98, 'full moon is lit, got ' .. lit_full) +assert(lit_new2 < 0.02, 'new moon is dark, got ' .. lit_new2) +assert(weather.moon(nil) == nil, 'missing epoch') + +-- 28 glyphs from U+E38D. Phase 0 and phase 1 are both the new moon. +assert(weather.moon_icon(0) == utf8.char(0xE38D), 'phase 0 is new moon') +assert(weather.moon_icon(0.5) == utf8.char(0xE38D + 14), 'phase 0.5 is full moon') +assert(weather.moon_icon(0.999) == utf8.char(0xE38D), 'phase near 1 wraps to new') +assert(weather.moon_icon(nil) == I.unknown, 'missing phase') -- === Wind ================================================================= --- OWM metric gives m/s; the card shows km/h. The conversion is where a wrong --- factor would look plausible but read 3.6x off. -assert(weather.kmh(10) == 36, '10 m/s is 36 km/h') -assert(weather.kmh(0) == 0, 'calm') -assert(weather.kmh(nil) == nil, 'missing wind speed stays missing') - --- Beaufort thresholds, in km/h, from the polybar script. Each assertion sits --- on a boundary and just past it, which is where an inclusive/exclusive --- mistake hides. -assert(weather.beaufort(0) == 0, 'calm is force 0') -assert(weather.beaufort(1) == 0, '1 km/h is still force 0') -assert(weather.beaufort(2) == 1, 'just over 1 is force 1') -assert(weather.beaufort(5) == 1, '5 is still force 1') -assert(weather.beaufort(6) == 2, 'just over 5 is force 2') -assert(weather.beaufort(11) == 2, '11 is still force 2') -assert(weather.beaufort(12) == 3, 'just over 11 is force 3') -assert(weather.beaufort(19) == 3, '19 is still force 3') -assert(weather.beaufort(28) == 4, '28 is force 4') -assert(weather.beaufort(38) == 5, '38 is force 5') -assert(weather.beaufort(49) == 6, '49 is force 6') -assert(weather.beaufort(61) == 7, '61 is force 7') -assert(weather.beaufort(74) == 8, '74 is force 8') -assert(weather.beaufort(88) == 9, '88 is force 9') -assert(weather.beaufort(102) == 10, '102 is force 10') -assert(weather.beaufort(117) == 11, '117 is force 11') -assert(weather.beaufort(118) == 12, 'over 117 is hurricane force') - -- Compass: eight points, each spanning 45 degrees, offset by half a step so --- north straddles 0 rather than starting at it. The wraparound is the case --- that a naive floor(deg/45) gets wrong. +-- north straddles 0 rather than starting at it. assert(weather.compass(0) == 'N', '0 is north') assert(weather.compass(360) == 'N', '360 wraps to north') -assert(weather.compass(11) == 'N', 'just under the NE boundary') +assert(weather.compass(22) == 'N', 'just under the NE boundary') +assert(weather.compass(23) == 'NE', 'just over it') assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north') -assert(weather.compass(45) == 'NE', '45 is northeast') assert(weather.compass(90) == 'E', '90 is east') -assert(weather.compass(135) == 'SE', '135 is southeast') assert(weather.compass(180) == 'S', '180 is south') -assert(weather.compass(225) == 'SW', '225 is southwest') assert(weather.compass(270) == 'W', '270 is west') assert(weather.compass(315) == 'NW', '315 is northwest') assert(weather.compass(nil) == nil, 'missing direction stays missing') --- The arrow points where the wind is going, and OWM reports where it comes --- from, so a north wind draws a downward arrow. -assert(weather.arrow(0) == '\u{2193}', 'a north wind blows southward') -assert(weather.arrow(180) == '\u{2191}', 'a south wind blows northward') -assert(weather.arrow(nil) == '', 'no direction draws nothing') - -- === Sun position ========================================================= --- t is the fraction of daylight elapsed, and the widget uses it to place the --- dot along the arc. Sunrise 1000, sunset 2000, so midday is 1500. +-- t is the fraction of daylight elapsed. Sunrise 1000, sunset 2000. assert(weather.sun_t(1000, 1000, 2000) == 0, 'at sunrise t is 0') assert(weather.sun_t(1500, 1000, 2000) == 0.5, 'at midday t is half') assert(weather.sun_t(2000, 1000, 2000) == 1, 'at sunset t is 1') - --- OWM reports today's sunrise and sunset, so between midnight and sunrise the --- numerator is negative. The clamp is the whole handling; without it the dot --- would be drawn off the left end of the arc. assert(weather.sun_t(500, 1000, 2000) == 0, 'before dawn clamps to 0') assert(weather.sun_t(9999, 1000, 2000) == 1, 'after dusk clamps to 1') - --- Degenerate input must not divide by zero. assert(weather.sun_t(1500, 2000, 2000) == 0, 'zero-length day gives 0, not nan') assert(weather.sun_t(nil, 1000, 2000) == nil, 'missing now gives nil') -assert(weather.sun_t(1500, nil, 2000) == nil, 'missing sunrise gives nil') --- is_day drives both the icon face and the dot's colour: a dot parked at the --- end of the arc must not read as a sun that is still up. assert(weather.is_day(1500, 1000, 2000) == true, 'midday is day') assert(weather.is_day(1000, 1000, 2000) == true, 'sunrise counts as day') -assert(weather.is_day(2000, 1000, 2000) == true, 'sunset counts as day') assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night') -assert(weather.is_day(2500, 1000, 2000) == false, 'after dusk is night') +assert(weather.is_day(nil, 1000, 2000) == true, 'unknown falls back to day') -- === Parsing the cache ==================================================== --- The parser reads scalars by key with Lua patterns rather than pulling in a --- JSON library: the response is flat and known, and six numbers do not justify --- a dependency. Caching the whole response still costs nothing, since a field --- added later is already on disk. -local function read(path) - local f = assert(io.open(path, 'r')) - local s = f:read('*a') - f:close() - return s -end - -local w = weather.parse(read('test/fixtures/weather.json')) -assert(w, 'the fixture must parse') -assert(w.id == 501, 'condition id, got ' .. tostring(w.id)) -assert(w.description == 'moderate rain', 'description, got ' .. tostring(w.description)) -assert(w.temp == 18.34, 'temp, got ' .. tostring(w.temp)) -assert(w.feels_like == 18.11, 'feels like, got ' .. tostring(w.feels_like)) -assert(w.humidity == 72, 'humidity, got ' .. tostring(w.humidity)) -assert(w.wind_speed == 4.12, 'wind m/s, got ' .. tostring(w.wind_speed)) -assert(w.wind_deg == 230, 'wind direction, got ' .. tostring(w.wind_deg)) -assert(w.sunrise == 1789600000, 'sunrise, got ' .. tostring(w.sunrise)) -assert(w.sunset == 1789646000, 'sunset, got ' .. tostring(w.sunset)) -assert(w.city == 'Example City', 'city, got ' .. tostring(w.city)) -assert(w.dt == 1789625000, 'observation time, got ' .. tostring(w.dt)) - --- temp comes before feels_like in the response and both live under "main", --- so a lazy pattern would read one for the other. They differ in the fixture --- precisely so this is checkable. -assert(w.temp ~= w.feels_like, 'temp and feels_like must not collapse') - --- Failure modes all return nil rather than raising: a Lua error here is a --- blank dashboard, not a message. -assert(weather.parse(read('test/fixtures/weather_truncated.json')) == nil, - 'a truncated response must give nil') -assert(weather.parse('') == nil, 'empty input gives nil') -assert(weather.parse('not json at all') == nil, 'garbage gives nil') -assert(weather.parse(nil) == nil, 'nil input gives nil') - --- An error body carries cod 401 and no weather. It must not parse as data. -assert(weather.parse('{"cod":401,"message":"Invalid API key."}') == nil, - 'an API error body must give nil') - --- Negative temperatures are ordinary here for half the year, and a number in --- exponent form must not be truncated at the 'e': reading 1.8e1 as 1.8 would --- draw 2 degrees instead of 18, wrong in a way that still looks like weather. -local cold = weather.parse( - '{"weather":[{"id":600,"description":"snow"}],"main":{"temp":-12.5},' .. - '"sys":{"sunrise":100,"sunset":200},"name":"X","dt":150}') -assert(cold and cold.temp == -12.5, 'a negative temp, got ' .. tostring(cold and cold.temp)) -local exp = weather.parse( - '{"weather":[{"id":800}],"main":{"temp":1.8e1},' .. - '"sys":{"sunrise":100,"sunset":200}}') -assert(exp and exp.temp == 18, 'exponent form, got ' .. tostring(exp and exp.temp)) +local w = weather.parse(slurp('test/fixtures/weather.txt')) +assert(w, 'the fixture parses') +assert(w.units == 'metric', 'units') +assert(w.current.dt == 1792325700, 'current time') +assert(w.current.temp == 13.2, 'current temperature') +assert(w.current.feels == 11.5, 'feels like') +assert(w.current.code == 3, 'current code') +assert(w.current.is_day == true, 'is_day 1 is day') +assert(w.current.humidity == 77, 'humidity') +assert(w.current.wind == 10.2, 'wind') +assert(w.current.wind_deg == 72, 'wind direction') +assert(w.sun.rise == 1792301818 and w.sun.set == 1792339510, 'sun times') +assert(w.air.aqi == 37, 'air quality') +assert(#w.hours == 3, 'three hours, got ' .. #w.hours) +assert(w.hours[1].uv == 1.15, 'first hour UV') +assert(w.hours[2].uv == nil, 'null UV reads as nil') +assert(w.hours[2].precip == 40, 'rain chance') +assert(w.hours[3].temp == nil, 'null temperature reads as nil') +assert(w.hours[3].is_day == false, 'is_day 0 is night') +assert(#w.days == 2, 'two days') +assert(w.days[1].min == 8.1 and w.days[1].max == 14.6, 'today min and max') +assert(w.days[2].precip == 80, 'tomorrow rain chance') + +-- No air line: still parses, air is nil, the card shows --. +local no_air = slurp('test/fixtures/weather.txt'):gsub('air\t37\n', '') +local wa = weather.parse(no_air) +assert(wa and wa.air == nil, 'missing air line is not fatal') + +-- Required records missing: nil, so the card draws its notice. +assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('current\t[^\n]*\n', ''))) == nil, + 'no current line is nil') +assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('sun\t[^\n]*\n', ''))) == nil, + 'no sun line is nil') +assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('hour\t[^\n]*\n', ''))) == nil, + 'no hour lines is nil') +assert(weather.parse('') == nil, 'empty string') +assert(weather.parse(nil) == nil, 'nil') +assert(weather.parse('{"cod":401,"message":"bad"}') == nil, 'garbage') -- === Staleness ============================================================ --- 45 minutes is three missed fetches at the 15-minute interval, so one --- transient failure does not flag the card. -assert(weather.is_stale(1000, 1000) == false, 'a fresh reading is not stale') -assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45 min is the boundary') -assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'past 45 min is stale') -assert(weather.is_stale(nil, 1000) == true, 'no observation time counts as stale') - --- The age string is what the card prints beside the city, so it must be short. -assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m') -assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h') -assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours') -assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad') - --- Neither takes `now` on faith: a nil there used to raise, and a raise is a --- blank dashboard rather than a message. -assert(weather.is_stale(1000, nil) == true, 'a nil now must not raise') -assert(weather.age_str(1000, nil) == '?', 'a nil now must not raise') - --- A dt in the future means a clock skew. "stale -17m" reads as a broken --- widget, so the age floors at zero and the reading counts as fresh. -assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m, got ' .. - tostring(weather.age_str(2000, 1000))) -assert(weather.is_stale(2000, 1000) == false, 'a future dt is not stale') +-- Three missed fetches at the 15-minute interval. +assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45m is not stale') +assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'over 45m is stale') +assert(weather.is_stale(nil, 1000) == true, 'no timestamp is stale') +assert(weather.age_str(1000, 1000 + 12 * 60) == '12m', 'minutes') +assert(weather.age_str(1000, 1000 + 3 * 3600) == '3h', 'hours') +assert(weather.age_str(1000, 1000 + 2 * 86400) == '2d', 'days') +assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m') +assert(weather.age_str(nil, 1000) == '?', 'missing dt') print('test_weather: all assertions passed') -- cgit v1.2.3