-- 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. 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 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. -- === 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. 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(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. 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') -- === 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)) -- === 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') print('test_weather: all assertions passed')