-- 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 cards themselves are verified -- by rendering them. package.path = './?.lua;' .. package.path local weather = require 'lib.weather' local function slurp(path) local f = assert(io.open(path, 'r')) local s = f:read('*a') f:close() return s end -- === 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 to nearest') assert(weather.deg(nil) == '--', 'missing temperature is --, never 0') -- Non-finite input must not raise (1e999 parses to inf from a cache line). local NAN, INF = 0/0, math.huge for _, bad in ipairs({ NAN, INF, -INF, 1e300, math.maxinteger }) do assert(weather.deg(bad) == '--', 'deg non-finite ' .. tostring(bad)) assert(weather.num(bad) == '--', 'num non-finite ' .. tostring(bad)) assert(weather.duration(bad) == '--', 'duration non-finite ' .. tostring(bad)) assert(weather.moon_icon(bad) == weather.ICON.unknown, 'moon_icon non-finite ' .. tostring(bad)) end assert(weather.sun_t(NAN, 1000, 2000) == 0, 'NaN now parks at 0') assert(weather.age_str(NAN, 0) == '?', 'NaN age is ?') 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 ================================================================= -- Compass: eight points, each spanning 45 degrees, offset by half a step so -- 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(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(90) == 'E', '90 is east') assert(weather.compass(180) == 'S', '180 is south') 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') -- === Sun position ========================================================= -- 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') 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') 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.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(500, 1000, 2000) == false, 'before dawn is night') assert(weather.is_day(nil, 1000, 2000) == true, 'unknown falls back to day') -- === Parsing the cache ==================================================== 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 ============================================================ -- 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')