aboutsummaryrefslogtreecommitdiffstats
path: root/test/test_weather.lua
blob: cdcdc2d4ebd31ff2138b3c7f69a8d1b5132c2d36 (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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
-- 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')