aboutsummaryrefslogtreecommitdiffstats
path: root/test/test_weather.lua
blob: cea464598f1a0f6cbd86711563470bcadb3f50f1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
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 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 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 =================================================================
-- 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')