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.
@@ -0,0 +1,3 @@
1
+ nowcastingcli
2
+ scripts
3
+ tests
@@ -0,0 +1,15 @@
1
+ # dev_shell.py
2
+ from datetime import datetime
3
+ from nowcastingcli.models import Observation
4
+ from nowcastingcli.display import format_observation
5
+
6
+ obs = Observation(
7
+ timestamp = datetime.now(),
8
+ pressure_raw = 1013.25,
9
+ pressure_qnh = 1015.80,
10
+ temperature = 18.5,
11
+ humidity = 62.0,
12
+ altitude = 667.0
13
+ )
14
+
15
+ print(format_observation(obs))
tests/__init__.py ADDED
File without changes
tests/test_display.py ADDED
@@ -0,0 +1,271 @@
1
+ # tests/test_display.py
2
+ # Run all tests in this file: pytest tests/test_display.py -v
3
+ import pytest
4
+ from unittest.mock import patch
5
+ from datetime import datetime
6
+
7
+ from nowcastingcli.models import Observation
8
+ from nowcastingcli.display import sparkline, trend_arrow, render_dashboard, format_observation, SPARKLINE_CHARS
9
+
10
+
11
+ VALID_TS = datetime(2024, 6, 1, 12, 0, 0)
12
+
13
+
14
+ def _obs(**overrides):
15
+ """Factory helper — same pattern as in test_models.py."""
16
+ defaults = dict(
17
+ timestamp=VALID_TS,
18
+ pressure_raw=1013.25,
19
+ pressure_qnh=1015.0,
20
+ temperature=20.0,
21
+ humidity=50.0,
22
+ altitude=100.0,
23
+ )
24
+ defaults.update(overrides)
25
+ return Observation(**defaults)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Shared fixture
30
+ # ---------------------------------------------------------------------------
31
+ # autouse=True silences Rich console output for every test in this file.
32
+ # Yielding the mock lets individual tests declare `mock_console` as a parameter
33
+ # to inspect calls — without needing a second patch() block inside the test.
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def mock_console():
38
+ with patch("nowcastingcli.display.console") as m:
39
+ yield m
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # sparkline
44
+ # ---------------------------------------------------------------------------
45
+ # sparkline() maps a list of floats to Unicode block characters (▁▂▃▄▅▆▇█).
46
+ # It is a pure function — no side effects — so tests just call it and assert.
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def test_sparkline_empty_returns_empty_string():
50
+ """Empty input → empty string (no characters to render).
51
+
52
+ Run: pytest tests/test_display.py::test_sparkline_empty_returns_empty_string -v
53
+ """
54
+ assert sparkline([]) == ""
55
+
56
+
57
+ def test_sparkline_length_matches_input():
58
+ """Output has exactly one character per input value.
59
+
60
+ Run: pytest tests/test_display.py::test_sparkline_length_matches_input -v
61
+ """
62
+ values = [1.0, 2.0, 3.0, 4.0, 5.0]
63
+ assert len(sparkline(values)) == len(values)
64
+
65
+
66
+ def test_sparkline_single_value_returns_one_char():
67
+ """Single value → one character; no IndexError or empty result.
68
+
69
+ Run: pytest tests/test_display.py::test_sparkline_single_value_returns_one_char -v
70
+ """
71
+ assert len(sparkline([42.0])) == 1
72
+
73
+
74
+ def test_sparkline_min_maps_to_lowest_char():
75
+ """The minimum value always maps to the first (lowest) block character.
76
+
77
+ Run: pytest tests/test_display.py::test_sparkline_min_maps_to_lowest_char -v
78
+ """
79
+ result = sparkline([0.0, 50.0, 100.0])
80
+ assert result[0] == SPARKLINE_CHARS[0] # "▁"
81
+
82
+
83
+ def test_sparkline_max_maps_to_highest_char():
84
+ """The maximum value always maps to the last (tallest) block character.
85
+
86
+ Run: pytest tests/test_display.py::test_sparkline_max_maps_to_highest_char -v
87
+ """
88
+ result = sparkline([0.0, 50.0, 100.0])
89
+ assert result[-1] == SPARKLINE_CHARS[-1] # "█"
90
+
91
+
92
+ def test_sparkline_all_same_values():
93
+ """When all values are equal, span collapses to 1.0 (guard against /0).
94
+ Every value maps to index 0 — all chars are the lowest block.
95
+
96
+ Run: pytest tests/test_display.py::test_sparkline_all_same_values -v
97
+ """
98
+ result = sparkline([5.0, 5.0, 5.0])
99
+ assert result == SPARKLINE_CHARS[0] * 3
100
+
101
+
102
+ def test_sparkline_ascending_chars_are_nondecreasing():
103
+ """Strictly ascending input → characters must also be non-decreasing.
104
+
105
+ Run: pytest tests/test_display.py::test_sparkline_ascending_chars_are_nondecreasing -v
106
+ """
107
+ result = sparkline([1.0, 2.0, 3.0, 4.0])
108
+ for a, b in zip(result, result[1:]):
109
+ assert a <= b
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # trend_arrow
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def test_trend_arrow_no_previous_returns_space():
117
+ """No previous reading → neutral space character (nothing to compare against).
118
+
119
+ Run: pytest tests/test_display.py::test_trend_arrow_no_previous_returns_space -v
120
+ """
121
+ assert trend_arrow(1013.0, None) == " "
122
+
123
+
124
+ def test_trend_arrow_rising_above_threshold():
125
+ """delta > threshold → upward arrow.
126
+
127
+ Run: pytest tests/test_display.py::test_trend_arrow_rising_above_threshold -v
128
+ """
129
+ assert trend_arrow(1015.0, 1013.0) == "↑" # delta = +2.0
130
+
131
+
132
+ def test_trend_arrow_falling_below_threshold():
133
+ """delta < -threshold → downward arrow.
134
+
135
+ Run: pytest tests/test_display.py::test_trend_arrow_falling_below_threshold -v
136
+ """
137
+ assert trend_arrow(1011.0, 1013.0) == "↓" # delta = -2.0
138
+
139
+
140
+ def test_trend_arrow_stable_within_threshold():
141
+ """Small delta inside threshold → stable arrow.
142
+
143
+ Run: pytest tests/test_display.py::test_trend_arrow_stable_within_threshold -v
144
+ """
145
+ assert trend_arrow(1013.05, 1013.0) == "→" # delta = +0.05 < default 0.1
146
+
147
+
148
+ def test_trend_arrow_just_below_threshold_is_stable():
149
+ """delta just below threshold → '→'; the check is strict (>) not (>=).
150
+
151
+ Floating-point note: 1013.1 - 1013.0 evaluates to 0.10000000000002274 in
152
+ IEEE 754, which is > 0.1, so those values would return '↑' — not the '→'
153
+ a naive "boundary = threshold" test would expect. Using 1013.09 keeps the
154
+ delta clearly below 0.1 regardless of rounding.
155
+
156
+ Run: pytest tests/test_display.py::test_trend_arrow_just_below_threshold_is_stable -v
157
+ """
158
+ assert trend_arrow(1013.09, 1013.0, threshold=0.1) == "→" # delta ≈ 0.09 < 0.1
159
+
160
+
161
+ def test_trend_arrow_custom_threshold():
162
+ """A higher threshold makes a normally-rising delta appear stable.
163
+
164
+ Run: pytest tests/test_display.py::test_trend_arrow_custom_threshold -v
165
+ """
166
+ assert trend_arrow(1015.0, 1013.0, threshold=5.0) == "→" # delta=2 < 5
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # render_dashboard
171
+ # ---------------------------------------------------------------------------
172
+ # render_dashboard() produces Rich terminal output — the return value is None.
173
+ # There is nothing to assert on directly, so tests use two strategies:
174
+ # 1. Smoke tests — call the function and assert it does not raise.
175
+ # 2. Interaction tests — inspect the mocked console to verify it was used.
176
+ # The mock_console fixture (autouse) handles suppressing terminal output.
177
+ # ---------------------------------------------------------------------------
178
+
179
+ @pytest.mark.smoke
180
+ def test_render_dashboard_single_observation_does_not_raise():
181
+ """One observation, no crash.
182
+
183
+ Smoke: Rich rendering bugs are otherwise invisible until someone runs
184
+ the CLI by hand — this catches a broken render_dashboard() in CI.
185
+
186
+ Run: pytest tests/test_display.py::test_render_dashboard_single_observation_does_not_raise -v
187
+ """
188
+ render_dashboard([_obs()])
189
+
190
+
191
+ def test_render_dashboard_multiple_observations_does_not_raise():
192
+ """Smoke test: three observations (enough to trigger trend arrows and sparkline delta).
193
+
194
+ Run: pytest tests/test_display.py::test_render_dashboard_multiple_observations_does_not_raise -v
195
+ """
196
+ obs_list = [
197
+ _obs(pressure_qnh=1013.0),
198
+ _obs(pressure_qnh=1011.0),
199
+ _obs(pressure_qnh=1009.0),
200
+ ]
201
+ render_dashboard(obs_list)
202
+
203
+
204
+ def test_render_dashboard_clears_screen(mock_console):
205
+ """render_dashboard must call console.clear() once to refresh the display.
206
+
207
+ mock_console is the fixture defined above — declaring it as a parameter
208
+ gives this test access to the mock object to assert on its calls.
209
+
210
+ Run: pytest tests/test_display.py::test_render_dashboard_clears_screen -v
211
+ """
212
+ render_dashboard([_obs()])
213
+ mock_console.clear.assert_called_once()
214
+
215
+
216
+ def test_render_dashboard_prints_to_console(mock_console):
217
+ """render_dashboard must call console.print() at least twice
218
+ (once for the table, once for the panel).
219
+
220
+ Run: pytest tests/test_display.py::test_render_dashboard_prints_to_console -v
221
+ """
222
+ render_dashboard([_obs()])
223
+ assert mock_console.print.call_count >= 2
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # format_observation
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def test_format_observation_contains_timestamp():
231
+ """Output must include the observation timestamp.
232
+
233
+ Run: pytest tests/test_display.py::test_format_observation_contains_timestamp -v
234
+ """
235
+ obs = _obs()
236
+ assert str(VALID_TS) in format_observation(obs)
237
+
238
+
239
+ def test_format_observation_contains_all_field_labels():
240
+ """Output must include all field labels.
241
+
242
+ Run: pytest tests/test_display.py::test_format_observation_contains_all_field_labels -v
243
+ """
244
+ text = format_observation(_obs())
245
+ for label in ("pressure_raw", "pressure_qnh", "temperature", "humidity", "altitude"):
246
+ assert label in text
247
+
248
+
249
+ def test_format_observation_contains_units():
250
+ """Output must include unit strings from the units dict.
251
+
252
+ Run: pytest tests/test_display.py::test_format_observation_contains_units -v
253
+ """
254
+ text = format_observation(_obs())
255
+ assert "hPa" in text
256
+ assert "°C" in text
257
+ assert "%" in text
258
+
259
+
260
+ def test_format_observation_contains_values():
261
+ """Output must include the numeric field values.
262
+
263
+ Run: pytest tests/test_display.py::test_format_observation_contains_values -v
264
+ """
265
+ text = format_observation(_obs(pressure_raw=999.9, temperature=-5.5, humidity=33.0, altitude=250.0))
266
+ assert "999.9" in text
267
+ assert "-5.5" in text
268
+ assert "33.0" in text
269
+ assert "250.0" in text
270
+
271
+
@@ -0,0 +1,85 @@
1
+ # tests/test_heuristics.py
2
+ # Run all tests in this file: pytest tests/test_heuristics.py -v
3
+ import pytest
4
+ from datetime import datetime, timedelta
5
+ from nowcastingcli.models import Observation
6
+ from nowcastingcli.heuristics import assess_conditions, WORSENING, STABLE, IMPROVING
7
+
8
+
9
+
10
+ # --- Fixture: factory function for Observations ---
11
+
12
+ def make_obs(pressure_qnh: float, humidity: float, temperature: float = 15.0, minutes_ago: int = 0) -> Observation:
13
+ return Observation(
14
+ timestamp=datetime.now() - timedelta(minutes=minutes_ago),
15
+ pressure_raw=pressure_qnh - 2.0, # raw is always less than QNH for our alt
16
+ pressure_qnh=pressure_qnh,
17
+ temperature=temperature,
18
+ humidity=humidity,
19
+ altitude=340.0,
20
+ )
21
+
22
+
23
+ # --- Insufficient data ---
24
+
25
+ @pytest.mark.smoke
26
+ def test_single_observation_returns_stable():
27
+ """Single reading, no trend possible yet.
28
+
29
+ Smoke: confirms assess_conditions() runs without error and returns a
30
+ sane default when there isn't enough history for a real verdict.
31
+
32
+ Run: pytest tests/test_heuristics.py::test_single_observation_returns_stable -v
33
+ """
34
+ obs = [make_obs(1013.0, 60.0)]
35
+ verdict, _ = assess_conditions(obs)
36
+ assert verdict == STABLE
37
+
38
+
39
+ # --- Worsening scenarios ---
40
+
41
+ def test_rapid_pressure_drop_is_worsening():
42
+ """Run: pytest tests/test_heuristics.py::test_rapid_pressure_drop_is_worsening -v"""
43
+ obs = [
44
+ make_obs(1013.0, 60.0, minutes_ago=30),
45
+ make_obs(1011.5, 72.0, minutes_ago=15),
46
+ make_obs(1009.8, 86.0, minutes_ago=0),
47
+ ]
48
+ verdict, reason = assess_conditions(obs)
49
+ assert verdict == WORSENING
50
+ assert reason # non-empty string
51
+
52
+
53
+ def test_high_humidity_alone_triggers_worsening():
54
+ """Run: pytest tests/test_heuristics.py::test_high_humidity_alone_triggers_worsening -v"""
55
+ obs = [
56
+ make_obs(1013.0, 60.0, minutes_ago=30),
57
+ make_obs(1013.0, 88.0, minutes_ago=0), # pressure stable, humidity spiked
58
+ ]
59
+ verdict, _ = assess_conditions(obs)
60
+ assert verdict == WORSENING
61
+
62
+
63
+ # --- Improving scenarios ---
64
+
65
+ def test_pressure_rise_is_improving():
66
+ """Run: pytest tests/test_heuristics.py::test_pressure_rise_is_improving -v"""
67
+ obs = [
68
+ make_obs(1008.0, 70.0, minutes_ago=30),
69
+ make_obs(1010.5, 55.0, minutes_ago=0),
70
+ ]
71
+ verdict, _ = assess_conditions(obs)
72
+ assert verdict == IMPROVING
73
+
74
+
75
+ # --- Stable scenario ---
76
+
77
+ def test_no_change_is_stable():
78
+ """Run: pytest tests/test_heuristics.py::test_no_change_is_stable -v"""
79
+ obs = [
80
+ make_obs(1013.0, 60.0, minutes_ago=30),
81
+ make_obs(1013.2, 61.0, minutes_ago=0),
82
+ ]
83
+ verdict, _ = assess_conditions(obs)
84
+ assert verdict == STABLE
85
+