nowcastingcli 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nowcastingcli/__init__.py +12 -0
- nowcastingcli/display.py +160 -0
- nowcastingcli/heuristics.py +65 -0
- nowcastingcli/logging_config.py +64 -0
- nowcastingcli/main.py +216 -0
- nowcastingcli/models.py +44 -0
- nowcastingcli/physics.py +65 -0
- nowcastingcli-0.6.1.dist-info/METADATA +16 -0
- nowcastingcli-0.6.1.dist-info/RECORD +19 -0
- nowcastingcli-0.6.1.dist-info/WHEEL +5 -0
- nowcastingcli-0.6.1.dist-info/entry_points.txt +2 -0
- nowcastingcli-0.6.1.dist-info/top_level.txt +3 -0
- scripts/Init_observation.py +15 -0
- tests/__init__.py +0 -0
- tests/test_display.py +271 -0
- tests/test_heuristics.py +85 -0
- tests/test_main.py +505 -0
- tests/test_models.py +130 -0
- tests/test_physics.py +125 -0
tests/test_main.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# tests/test_main.py
|
|
2
|
+
# Run all tests in this file: pytest tests/test_main.py -v
|
|
3
|
+
import sys
|
|
4
|
+
import pytest
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
from nowcastingcli.main import get_float, run, edit_observation, _parse_csv, cli
|
|
7
|
+
from nowcastingcli.models import Observation
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# autouse fixture — applied automatically to every test in this file.
|
|
13
|
+
# It replaces the Rich `console` object inside main.py with a silent mock so
|
|
14
|
+
# console.print() calls don't produce output during test runs.
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def silence_console():
|
|
19
|
+
"""Suppress all Rich console.print() calls made by main.py.
|
|
20
|
+
|
|
21
|
+
autouse=True means pytest activates this fixture for every test in this
|
|
22
|
+
file without each test having to declare it explicitly. The `with` block
|
|
23
|
+
keeps the patch active for the duration of the test, then restores the
|
|
24
|
+
real console automatically.
|
|
25
|
+
"""
|
|
26
|
+
with patch("nowcastingcli.main.console"):
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# get_float
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# get_float() wraps Prompt.ask() in a retry loop: it keeps asking until the
|
|
34
|
+
# user enters a number that falls inside [min_val, max_val].
|
|
35
|
+
# We use patch() to replace Prompt.ask with a mock whose side_effect list
|
|
36
|
+
# acts as a queue of pre-scripted "user answers".
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def test_get_float_returns_valid_value():
|
|
40
|
+
"""Valid input on the first try — returns immediately.
|
|
41
|
+
|
|
42
|
+
Run: pytest tests/test_main.py::test_get_float_returns_valid_value -v
|
|
43
|
+
"""
|
|
44
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="20.0"):
|
|
45
|
+
assert get_float("Temperature", -60, 60) == 20.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_get_float_accepts_min_boundary():
|
|
49
|
+
"""Exactly at min_val — boundary is inclusive.
|
|
50
|
+
|
|
51
|
+
Run: pytest tests/test_main.py::test_get_float_accepts_min_boundary -v
|
|
52
|
+
"""
|
|
53
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="-60.0"):
|
|
54
|
+
assert get_float("Temperature", -60, 60) == -60.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_get_float_accepts_max_boundary():
|
|
58
|
+
"""Exactly at max_val — boundary is inclusive.
|
|
59
|
+
|
|
60
|
+
Run: pytest tests/test_main.py::test_get_float_accepts_max_boundary -v
|
|
61
|
+
"""
|
|
62
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="60.0"):
|
|
63
|
+
assert get_float("Temperature", -60, 60) == 60.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_get_float_retries_when_out_of_range():
|
|
67
|
+
"""Out-of-range answer triggers a retry; the second valid answer is returned.
|
|
68
|
+
|
|
69
|
+
side_effect with a list makes the mock return each item in sequence:
|
|
70
|
+
first call → "200.0" (rejected), second call → "20.0" (accepted).
|
|
71
|
+
|
|
72
|
+
Run: pytest tests/test_main.py::test_get_float_retries_when_out_of_range -v
|
|
73
|
+
"""
|
|
74
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["200.0", "20.0"]):
|
|
75
|
+
assert get_float("Temperature", -60, 60) == 20.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_get_float_retries_on_non_numeric():
|
|
79
|
+
"""Non-numeric input raises ValueError internally; the loop retries.
|
|
80
|
+
|
|
81
|
+
Run: pytest tests/test_main.py::test_get_float_retries_on_non_numeric -v
|
|
82
|
+
"""
|
|
83
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["abc", "20.0"]):
|
|
84
|
+
assert get_float("Temperature", -60, 60) == 20.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# run
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# run() calls Prompt.ask for pressure (directly) and then for temperature,
|
|
91
|
+
# humidity, and altitude (via get_float). The call order within one cycle is:
|
|
92
|
+
# 1. Prompt.ask → pressure (or "q" to quit)
|
|
93
|
+
# 2. Prompt.ask → temperature (get_float)
|
|
94
|
+
# 3. Prompt.ask → humidity (get_float)
|
|
95
|
+
# 4. Prompt.ask → altitude (get_float)
|
|
96
|
+
#
|
|
97
|
+
# We also patch render_dashboard to prevent terminal output and to inspect
|
|
98
|
+
# which observations were passed to it.
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def test_run_quits_on_lowercase_q():
|
|
102
|
+
"""'q' at the pressure prompt exits immediately without creating an observation.
|
|
103
|
+
|
|
104
|
+
Run: pytest tests/test_main.py::test_run_quits_on_lowercase_q -v
|
|
105
|
+
"""
|
|
106
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="q"):
|
|
107
|
+
run() # must return, not hang
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_run_quits_on_uppercase_Q():
|
|
111
|
+
"""The quit check is case-insensitive: 'Q' works the same as 'q'.
|
|
112
|
+
|
|
113
|
+
Run: pytest tests/test_main.py::test_run_quits_on_uppercase_Q -v
|
|
114
|
+
"""
|
|
115
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="Q"):
|
|
116
|
+
run()
|
|
117
|
+
|
|
118
|
+
@pytest.mark.smoke
|
|
119
|
+
def test_run_one_full_observation_cycle():
|
|
120
|
+
"""One valid reading is stored and the dashboard is rendered once, then 'q' exits.
|
|
121
|
+
|
|
122
|
+
The five side_effect values map to the five Prompt.ask calls:
|
|
123
|
+
"1013.25" → pressure, "20.0" → temperature, "50.0" → humidity,
|
|
124
|
+
"100.0" → altitude, "q" → second pressure prompt (quit).
|
|
125
|
+
|
|
126
|
+
Smoke: only test that wires input → models → physics → heuristics →
|
|
127
|
+
display together end-to-end; a failure here means the app is broken
|
|
128
|
+
even if every unit test elsewhere passes.
|
|
129
|
+
|
|
130
|
+
Run: pytest tests/test_main.py::test_run_one_full_observation_cycle -v
|
|
131
|
+
"""
|
|
132
|
+
prompts = ["1013.25", "20.0", "50.0", "100.0", "q"]
|
|
133
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=prompts), \
|
|
134
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
135
|
+
run()
|
|
136
|
+
|
|
137
|
+
mock_render.assert_called_once()
|
|
138
|
+
observations = mock_render.call_args[0][0] # first positional arg of last call
|
|
139
|
+
assert len(observations) == 1
|
|
140
|
+
assert observations[0].pressure_raw == pytest.approx(1013.25)
|
|
141
|
+
assert observations[0].temperature == pytest.approx(20.0)
|
|
142
|
+
assert observations[0].humidity == pytest.approx(50.0)
|
|
143
|
+
assert observations[0].altitude == pytest.approx(100.0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_run_accumulates_multiple_observations():
|
|
147
|
+
"""Two readings accumulate in the list before 'q' quits.
|
|
148
|
+
|
|
149
|
+
Run: pytest tests/test_main.py::test_run_accumulates_multiple_observations -v
|
|
150
|
+
"""
|
|
151
|
+
prompts = [
|
|
152
|
+
"1013.25", "20.0", "50.0", "100.0", # first observation
|
|
153
|
+
"1005.0", "15.0", "60.0", "200.0", # second observation
|
|
154
|
+
"q",
|
|
155
|
+
]
|
|
156
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=prompts), \
|
|
157
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
158
|
+
run()
|
|
159
|
+
|
|
160
|
+
assert mock_render.call_count == 2
|
|
161
|
+
final_observations = mock_render.call_args[0][0]
|
|
162
|
+
assert len(final_observations) == 2
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_run_exits_on_keyboard_interrupt():
|
|
166
|
+
"""KeyboardInterrupt (Ctrl-C) breaks the loop; run() must not re-raise it.
|
|
167
|
+
|
|
168
|
+
Run: pytest tests/test_main.py::test_run_exits_on_keyboard_interrupt -v
|
|
169
|
+
"""
|
|
170
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=KeyboardInterrupt):
|
|
171
|
+
run()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_run_exits_on_eof():
|
|
175
|
+
"""EOFError (e.g. stdin closed / piped input exhausted) exits gracefully.
|
|
176
|
+
|
|
177
|
+
Run: pytest tests/test_main.py::test_run_exits_on_eof -v
|
|
178
|
+
"""
|
|
179
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=EOFError):
|
|
180
|
+
run()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- Regression: invalid pressure input must not crash ---
|
|
184
|
+
# Before the fix, pressure was read with a raw float() call that bypassed
|
|
185
|
+
# validation. Entering 0 or a negative value would reach normalize_pressure()
|
|
186
|
+
# and raise an unhandled ValueError. The loop now validates pressure inline
|
|
187
|
+
# and uses `continue` to re-prompt rather than crashing.
|
|
188
|
+
|
|
189
|
+
def test_run_zero_pressure_does_not_crash():
|
|
190
|
+
"""Entering '0' for pressure must be rejected and re-prompt, not crash.
|
|
191
|
+
|
|
192
|
+
side_effect sequence: '0' is rejected → 'q' exits the session.
|
|
193
|
+
render_dashboard is never called because no valid observation was created.
|
|
194
|
+
|
|
195
|
+
Run: pytest tests/test_main.py::test_run_zero_pressure_does_not_crash -v
|
|
196
|
+
"""
|
|
197
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["0", "q"]), \
|
|
198
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
199
|
+
run()
|
|
200
|
+
mock_render.assert_not_called()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_run_negative_pressure_does_not_crash():
|
|
204
|
+
"""Entering a negative pressure must be rejected and re-prompt, not crash.
|
|
205
|
+
|
|
206
|
+
Run: pytest tests/test_main.py::test_run_negative_pressure_does_not_crash -v
|
|
207
|
+
"""
|
|
208
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["-5", "q"]), \
|
|
209
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
210
|
+
run()
|
|
211
|
+
mock_render.assert_not_called()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_run_non_numeric_pressure_does_not_crash():
|
|
215
|
+
"""Entering a non-numeric string for pressure must be rejected gracefully.
|
|
216
|
+
|
|
217
|
+
Run: pytest tests/test_main.py::test_run_non_numeric_pressure_does_not_crash -v
|
|
218
|
+
"""
|
|
219
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["abc", "q"]), \
|
|
220
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
221
|
+
run()
|
|
222
|
+
mock_render.assert_not_called()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_run_recovers_after_bad_pressure():
|
|
226
|
+
"""After a rejected pressure, the next valid entry completes a full cycle.
|
|
227
|
+
|
|
228
|
+
Sequence: '0' rejected → '1013.25' accepted → temperature, humidity,
|
|
229
|
+
altitude → 'q' exits. One observation must be created.
|
|
230
|
+
|
|
231
|
+
Run: pytest tests/test_main.py::test_run_recovers_after_bad_pressure -v
|
|
232
|
+
"""
|
|
233
|
+
prompts = ["0", "1013.25", "20.0", "50.0", "100.0", "q"]
|
|
234
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=prompts), \
|
|
235
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
236
|
+
run()
|
|
237
|
+
mock_render.assert_called_once()
|
|
238
|
+
assert len(mock_render.call_args[0][0]) == 1
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# edit_observation
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _make_obs(**overrides) -> Observation:
|
|
246
|
+
defaults = dict(
|
|
247
|
+
timestamp=datetime(2024, 6, 1, 12, 0, 0),
|
|
248
|
+
pressure_raw=1013.25,
|
|
249
|
+
pressure_qnh=1015.0,
|
|
250
|
+
temperature=20.0,
|
|
251
|
+
humidity=50.0,
|
|
252
|
+
altitude=100.0,
|
|
253
|
+
)
|
|
254
|
+
defaults.update(overrides)
|
|
255
|
+
return Observation(**defaults)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_edit_observation_updates_temperature():
|
|
259
|
+
"""Selecting field 2 (temperature) updates the observation's temperature.
|
|
260
|
+
|
|
261
|
+
Prompt sequence: index "1" → field "2" → new value "25.0".
|
|
262
|
+
|
|
263
|
+
Run: pytest tests/test_main.py::test_edit_observation_updates_temperature -v
|
|
264
|
+
"""
|
|
265
|
+
obs = _make_obs()
|
|
266
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["1", "2", "25.0"]), \
|
|
267
|
+
patch("nowcastingcli.main.render_dashboard"):
|
|
268
|
+
edit_observation([obs])
|
|
269
|
+
assert obs.temperature == pytest.approx(25.0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_edit_observation_updates_humidity_only():
|
|
273
|
+
"""Field 3 (humidity) update must NOT change pressure_qnh.
|
|
274
|
+
|
|
275
|
+
Run: pytest tests/test_main.py::test_edit_observation_updates_humidity_only -v
|
|
276
|
+
"""
|
|
277
|
+
obs = _make_obs(pressure_qnh=1015.0)
|
|
278
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["1", "3", "80.0"]), \
|
|
279
|
+
patch("nowcastingcli.main.render_dashboard"):
|
|
280
|
+
edit_observation([obs])
|
|
281
|
+
assert obs.humidity == pytest.approx(80.0)
|
|
282
|
+
assert obs.pressure_qnh == pytest.approx(1015.0)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_edit_observation_rederives_qnh_on_pressure_change():
|
|
286
|
+
"""Field 1 (pressure_raw) change must re-derive pressure_qnh.
|
|
287
|
+
|
|
288
|
+
Run: pytest tests/test_main.py::test_edit_observation_rederives_qnh_on_pressure_change -v
|
|
289
|
+
"""
|
|
290
|
+
obs = _make_obs(pressure_raw=1013.25, pressure_qnh=1015.0)
|
|
291
|
+
original_qnh = obs.pressure_qnh
|
|
292
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["1", "1", "1000.0"]), \
|
|
293
|
+
patch("nowcastingcli.main.render_dashboard"):
|
|
294
|
+
edit_observation([obs])
|
|
295
|
+
assert obs.pressure_raw == pytest.approx(1000.0)
|
|
296
|
+
assert obs.pressure_qnh != pytest.approx(original_qnh)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_edit_observation_invalid_index_returns_early():
|
|
300
|
+
"""An out-of-range index prints an error and leaves observations unchanged.
|
|
301
|
+
|
|
302
|
+
Run: pytest tests/test_main.py::test_edit_observation_invalid_index_returns_early -v
|
|
303
|
+
"""
|
|
304
|
+
obs = _make_obs()
|
|
305
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["99"]), \
|
|
306
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
307
|
+
edit_observation([obs])
|
|
308
|
+
mock_render.assert_not_called()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_edit_observation_invalid_field_returns_early():
|
|
312
|
+
"""An unrecognised field choice prints an error and leaves observations unchanged.
|
|
313
|
+
|
|
314
|
+
Run: pytest tests/test_main.py::test_edit_observation_invalid_field_returns_early -v
|
|
315
|
+
"""
|
|
316
|
+
obs = _make_obs(temperature=20.0)
|
|
317
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["1", "9"]), \
|
|
318
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
319
|
+
edit_observation([obs])
|
|
320
|
+
mock_render.assert_not_called()
|
|
321
|
+
assert obs.temperature == pytest.approx(20.0)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_run_edit_with_no_observations_shows_warning():
|
|
325
|
+
"""'e' before any observation is entered shows a warning and loops back.
|
|
326
|
+
|
|
327
|
+
Prompt sequence: 'e' (no obs yet) → 'q' exits.
|
|
328
|
+
render_dashboard must never be called.
|
|
329
|
+
|
|
330
|
+
Run: pytest tests/test_main.py::test_run_edit_with_no_observations_shows_warning -v
|
|
331
|
+
"""
|
|
332
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["e", "q"]), \
|
|
333
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
334
|
+
run()
|
|
335
|
+
mock_render.assert_not_called()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_run_edit_flow_updates_existing_observation():
|
|
339
|
+
"""Full run: one observation added, then edited via 'e', then 'q' exits.
|
|
340
|
+
|
|
341
|
+
Prompt sequence:
|
|
342
|
+
"1013.25", "20.0", "50.0", "100.0" → first observation
|
|
343
|
+
"e" → enter edit mode
|
|
344
|
+
"1", "2", "25.0" → edit obs 1, field temperature, new value
|
|
345
|
+
"q" → quit
|
|
346
|
+
|
|
347
|
+
Run: pytest tests/test_main.py::test_run_edit_flow_updates_existing_observation -v
|
|
348
|
+
"""
|
|
349
|
+
prompts = ["1013.25", "20.0", "50.0", "100.0", "e", "1", "2", "25.0", "q"]
|
|
350
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=prompts), \
|
|
351
|
+
patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
352
|
+
run()
|
|
353
|
+
final_obs = mock_render.call_args[0][0]
|
|
354
|
+
assert final_obs[0].temperature == pytest.approx(25.0)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# edit_observation — field 4 (altitude)
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def test_edit_observation_updates_altitude():
|
|
362
|
+
"""Field 4 (altitude) update must change altitude and re-derive pressure_qnh.
|
|
363
|
+
|
|
364
|
+
Run: pytest tests/test_main.py::test_edit_observation_updates_altitude -v
|
|
365
|
+
"""
|
|
366
|
+
obs = _make_obs(altitude=100.0)
|
|
367
|
+
original_qnh = obs.pressure_qnh
|
|
368
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["1", "4", "500.0"]), \
|
|
369
|
+
patch("nowcastingcli.main.render_dashboard"):
|
|
370
|
+
edit_observation([obs])
|
|
371
|
+
assert obs.altitude == pytest.approx(500.0)
|
|
372
|
+
assert obs.pressure_qnh != pytest.approx(original_qnh)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# _parse_csv
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# Uses pytest's tmp_path fixture to write real CSV files to a temp directory.
|
|
379
|
+
# tmp_path is a pathlib.Path; _parse_csv expects a str, so we cast with str().
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def test_parse_csv_valid_file(tmp_path):
|
|
383
|
+
"""Valid CSV returns a list of _InputRow with correct field values.
|
|
384
|
+
|
|
385
|
+
Run: pytest tests/test_main.py::test_parse_csv_valid_file -v
|
|
386
|
+
"""
|
|
387
|
+
f = tmp_path / "obs.csv"
|
|
388
|
+
f.write_text("pressure_hpa,temperature_c,humidity_pct,altitude_m\n1013,18,60,340\n")
|
|
389
|
+
rows = _parse_csv(str(f))
|
|
390
|
+
assert len(rows) == 1
|
|
391
|
+
assert rows[0].pressure == pytest.approx(1013.0)
|
|
392
|
+
assert rows[0].temperature == pytest.approx(18.0)
|
|
393
|
+
assert rows[0].humidity == pytest.approx(60.0)
|
|
394
|
+
assert rows[0].altitude == pytest.approx(340.0)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_parse_csv_missing_column(tmp_path):
|
|
398
|
+
"""CSV with missing columns raises ValueError naming the absent columns.
|
|
399
|
+
|
|
400
|
+
Run: pytest tests/test_main.py::test_parse_csv_missing_column -v
|
|
401
|
+
"""
|
|
402
|
+
f = tmp_path / "bad.csv"
|
|
403
|
+
f.write_text("pressure_hpa,temperature_c\n1013,18\n")
|
|
404
|
+
with pytest.raises(ValueError, match="missing columns"):
|
|
405
|
+
_parse_csv(str(f))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_parse_csv_non_numeric_value(tmp_path):
|
|
409
|
+
"""A non-numeric cell raises ValueError that includes the row number.
|
|
410
|
+
|
|
411
|
+
Run: pytest tests/test_main.py::test_parse_csv_non_numeric_value -v
|
|
412
|
+
"""
|
|
413
|
+
f = tmp_path / "bad.csv"
|
|
414
|
+
f.write_text("pressure_hpa,temperature_c,humidity_pct,altitude_m\n1013,abc,60,340\n")
|
|
415
|
+
with pytest.raises(ValueError, match="Row 2"):
|
|
416
|
+
_parse_csv(str(f))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_parse_csv_pressure_out_of_range(tmp_path):
|
|
420
|
+
"""A pressure value outside [0.1, 1100.0] raises ValueError.
|
|
421
|
+
|
|
422
|
+
Run: pytest tests/test_main.py::test_parse_csv_pressure_out_of_range -v
|
|
423
|
+
"""
|
|
424
|
+
f = tmp_path / "bad.csv"
|
|
425
|
+
f.write_text("pressure_hpa,temperature_c,humidity_pct,altitude_m\n9999,18,60,340\n")
|
|
426
|
+
with pytest.raises(ValueError, match="pressure"):
|
|
427
|
+
_parse_csv(str(f))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_parse_csv_empty_file(tmp_path):
|
|
431
|
+
"""A CSV with only a header row raises ValueError about no data rows.
|
|
432
|
+
|
|
433
|
+
Run: pytest tests/test_main.py::test_parse_csv_empty_file -v
|
|
434
|
+
"""
|
|
435
|
+
f = tmp_path / "empty.csv"
|
|
436
|
+
f.write_text("pressure_hpa,temperature_c,humidity_pct,altitude_m\n")
|
|
437
|
+
with pytest.raises(ValueError, match="no data rows"):
|
|
438
|
+
_parse_csv(str(f))
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# run() — file input mode
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def test_run_with_valid_csv(tmp_path):
|
|
446
|
+
"""run(input_file=...) processes all CSV rows and calls render_dashboard once per row.
|
|
447
|
+
|
|
448
|
+
Run: pytest tests/test_main.py::test_run_with_valid_csv -v
|
|
449
|
+
"""
|
|
450
|
+
f = tmp_path / "obs.csv"
|
|
451
|
+
f.write_text(
|
|
452
|
+
"pressure_hpa,temperature_c,humidity_pct,altitude_m\n"
|
|
453
|
+
"1013,18,60,340\n"
|
|
454
|
+
"1011.5,17,72,340\n"
|
|
455
|
+
)
|
|
456
|
+
with patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
457
|
+
run(input_file=str(f))
|
|
458
|
+
assert mock_render.call_count == 2
|
|
459
|
+
final_obs = mock_render.call_args[0][0]
|
|
460
|
+
assert len(final_obs) == 2
|
|
461
|
+
assert final_obs[0].pressure_raw == pytest.approx(1013.0)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_run_with_invalid_csv_shows_error(tmp_path):
|
|
465
|
+
"""run(input_file=...) with a bad CSV prints an error and never renders.
|
|
466
|
+
|
|
467
|
+
Run: pytest tests/test_main.py::test_run_with_invalid_csv_shows_error -v
|
|
468
|
+
"""
|
|
469
|
+
f = tmp_path / "bad.csv"
|
|
470
|
+
f.write_text("wrong_column\n1013\n")
|
|
471
|
+
with patch("nowcastingcli.main.render_dashboard") as mock_render:
|
|
472
|
+
run(input_file=str(f))
|
|
473
|
+
mock_render.assert_not_called()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
# cli()
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
# monkeypatch.setattr replaces sys.argv for the duration of the test only.
|
|
480
|
+
# We patch run() itself so cli() is tested in isolation — we only verify that
|
|
481
|
+
# it parses argv correctly and passes the right argument to run().
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def test_cli_no_input_calls_run_with_none(monkeypatch):
|
|
485
|
+
"""cli() with no --input flag calls run(input_file=None).
|
|
486
|
+
|
|
487
|
+
Run: pytest tests/test_main.py::test_cli_no_input_calls_run_with_none -v
|
|
488
|
+
"""
|
|
489
|
+
monkeypatch.setattr(sys, "argv", ["nowcastingcli"])
|
|
490
|
+
with patch("nowcastingcli.main.run") as mock_run:
|
|
491
|
+
cli()
|
|
492
|
+
mock_run.assert_called_once_with(input_file=None)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_cli_with_input_calls_run(tmp_path, monkeypatch):
|
|
496
|
+
"""cli() with --input FILE passes the path to run(input_file=FILE).
|
|
497
|
+
|
|
498
|
+
Run: pytest tests/test_main.py::test_cli_with_input_calls_run -v
|
|
499
|
+
"""
|
|
500
|
+
f = tmp_path / "obs.csv"
|
|
501
|
+
f.write_text("pressure_hpa,temperature_c,humidity_pct,altitude_m\n1013,18,60,340\n")
|
|
502
|
+
monkeypatch.setattr(sys, "argv", ["nowcastingcli", "--input", str(f)])
|
|
503
|
+
with patch("nowcastingcli.main.run") as mock_run:
|
|
504
|
+
cli()
|
|
505
|
+
mock_run.assert_called_once_with(input_file=str(f))
|
tests/test_models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# tests/test_models.py
|
|
2
|
+
# Run all tests in this file: pytest tests/test_models.py -v
|
|
3
|
+
import pytest
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from nowcastingcli.models import Observation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VALID_TS = datetime(2024, 6, 1, 12, 0, 0)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _obs(**overrides):
|
|
12
|
+
"""Factory helper that creates a valid Observation with sensible defaults.
|
|
13
|
+
|
|
14
|
+
The leading underscore signals that this is a private test helper, not a
|
|
15
|
+
test itself — pytest will not collect it as a test case.
|
|
16
|
+
|
|
17
|
+
Syntax explained
|
|
18
|
+
----------------
|
|
19
|
+
**overrides (in the signature)
|
|
20
|
+
The double-star prefix makes Python collect any keyword arguments the
|
|
21
|
+
caller passes into a single dict called `overrides`. For example:
|
|
22
|
+
|
|
23
|
+
_obs(humidity=90.0, altitude=500.0)
|
|
24
|
+
# → overrides == {"humidity": 90.0, "altitude": 500.0}
|
|
25
|
+
|
|
26
|
+
defaults.update(overrides)
|
|
27
|
+
dict.update() merges one dict into another, overwriting any keys that
|
|
28
|
+
already exist. So `overrides` silently replaces only the fields the
|
|
29
|
+
caller specified; every other field keeps its default value.
|
|
30
|
+
|
|
31
|
+
defaults == {"humidity": 50.0, "altitude": 100.0, ...}
|
|
32
|
+
defaults.update({"humidity": 90.0, "altitude": 500.0})
|
|
33
|
+
# → defaults == {"humidity": 90.0, "altitude": 500.0, ...}
|
|
34
|
+
|
|
35
|
+
Observation(**defaults) (in the return statement)
|
|
36
|
+
The double-star prefix *unpacks* the dict as keyword arguments,
|
|
37
|
+
which is the reverse of collecting them. It is equivalent to
|
|
38
|
+
writing every key=value pair by hand:
|
|
39
|
+
|
|
40
|
+
Observation(timestamp=VALID_TS, pressure_raw=1013.25, ...)
|
|
41
|
+
|
|
42
|
+
Combined pattern
|
|
43
|
+
----------------
|
|
44
|
+
This two-step idiom (collect with **overrides, merge with .update(),
|
|
45
|
+
unpack with **defaults) lets each test override only the one field it
|
|
46
|
+
cares about, while keeping every other field at a known-good value:
|
|
47
|
+
|
|
48
|
+
def test_humidity_above_100_raises():
|
|
49
|
+
_obs(humidity=100.1) # only humidity changes; rest stay default
|
|
50
|
+
"""
|
|
51
|
+
defaults = dict(
|
|
52
|
+
timestamp=VALID_TS,
|
|
53
|
+
pressure_raw=1013.25,
|
|
54
|
+
pressure_qnh=1015.0,
|
|
55
|
+
temperature=20.0,
|
|
56
|
+
humidity=50.0,
|
|
57
|
+
altitude=100.0,
|
|
58
|
+
)
|
|
59
|
+
defaults.update(overrides)
|
|
60
|
+
return Observation(**defaults)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- __post_init__ validation ---
|
|
64
|
+
|
|
65
|
+
@pytest.mark.smoke
|
|
66
|
+
def test_valid_observation_is_created():
|
|
67
|
+
"""Happy path: all fields in range, no exception raised.
|
|
68
|
+
|
|
69
|
+
Smoke: confirms the Observation dataclass still constructs at all —
|
|
70
|
+
every other module and test depends on this succeeding.
|
|
71
|
+
|
|
72
|
+
Run: pytest tests/test_models.py::test_valid_observation_is_created -v
|
|
73
|
+
"""
|
|
74
|
+
obs = _obs()
|
|
75
|
+
assert obs.humidity == 50.0
|
|
76
|
+
assert obs.pressure_raw == 1013.25
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_humidity_zero_is_valid():
|
|
80
|
+
"""Boundary: humidity=0 is valid.
|
|
81
|
+
|
|
82
|
+
Run: pytest tests/test_models.py::test_humidity_zero_is_valid -v
|
|
83
|
+
"""
|
|
84
|
+
obs = _obs(humidity=0.0)
|
|
85
|
+
assert obs.humidity == 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_humidity_100_is_valid():
|
|
89
|
+
"""Boundary: humidity=100 is valid.
|
|
90
|
+
|
|
91
|
+
Run: pytest tests/test_models.py::test_humidity_100_is_valid -v
|
|
92
|
+
"""
|
|
93
|
+
obs = _obs(humidity=100.0)
|
|
94
|
+
assert obs.humidity == 100.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_humidity_below_zero_raises():
|
|
98
|
+
"""humidity < 0 must raise ValueError.
|
|
99
|
+
|
|
100
|
+
Run: pytest tests/test_models.py::test_humidity_below_zero_raises -v
|
|
101
|
+
"""
|
|
102
|
+
with pytest.raises(ValueError, match="humidity"):
|
|
103
|
+
_obs(humidity=-0.1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_humidity_above_100_raises():
|
|
107
|
+
"""humidity > 100 must raise ValueError.
|
|
108
|
+
|
|
109
|
+
Run: pytest tests/test_models.py::test_humidity_above_100_raises -v
|
|
110
|
+
"""
|
|
111
|
+
with pytest.raises(ValueError, match="humidity"):
|
|
112
|
+
_obs(humidity=100.1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_zero_pressure_raises():
|
|
116
|
+
"""pressure_raw=0 must raise ValueError.
|
|
117
|
+
|
|
118
|
+
Run: pytest tests/test_models.py::test_zero_pressure_raises -v
|
|
119
|
+
"""
|
|
120
|
+
with pytest.raises(ValueError, match="pressure"):
|
|
121
|
+
_obs(pressure_raw=0.0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_negative_pressure_raises():
|
|
125
|
+
"""Negative pressure_raw must raise ValueError.
|
|
126
|
+
|
|
127
|
+
Run: pytest tests/test_models.py::test_negative_pressure_raises -v
|
|
128
|
+
"""
|
|
129
|
+
with pytest.raises(ValueError, match="pressure"):
|
|
130
|
+
_obs(pressure_raw=-1.0)
|