File size: 17,449 Bytes
2532605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
"""
KRONECTOR β€” Data Pipeline Tests
Tests for fastf1_pipeline, jolpica_pipeline, build_driver_map, and merge logic.

Run: python -m pytest tests/test_data_pipelines.py -v
"""

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pandas as pd
import pytest
import requests


# ===================================================================
# Test: Driver Map
# ===================================================================


class TestDriverMap:
    """Tests for data.build_driver_map module."""

    def test_known_driver_map_has_key_drivers(self):
        """KNOWN_DRIVER_MAP must include key F1 drivers 2014–2024."""
        from data.build_driver_map import KNOWN_DRIVER_MAP

        assert "VER" in KNOWN_DRIVER_MAP
        assert "HAM" in KNOWN_DRIVER_MAP
        assert "LEC" in KNOWN_DRIVER_MAP
        assert "NOR" in KNOWN_DRIVER_MAP
        assert "ALO" in KNOWN_DRIVER_MAP

    def test_known_map_values_are_jolpica_slugs(self):
        """All values should be lowercase slug format."""
        from data.build_driver_map import KNOWN_DRIVER_MAP

        for abbrev, slug in KNOWN_DRIVER_MAP.items():
            assert abbrev == abbrev.upper(), (
                f"Key {abbrev} should be uppercase"
            )
            assert slug == slug.lower(), (
                f"Slug {slug} for {abbrev} should be lowercase"
            )

    def test_driver_map_is_importable(self):
        """DRIVER_MAP should be importable as module-level constant."""
        from data.build_driver_map import DRIVER_MAP

        assert isinstance(DRIVER_MAP, dict)
        assert len(DRIVER_MAP) > 0

    def test_abbreviations_are_3_letters(self):
        """FastF1 abbreviations should be 2–3 uppercase letters."""
        from data.build_driver_map import KNOWN_DRIVER_MAP

        for abbrev in KNOWN_DRIVER_MAP.keys():
            assert 2 <= len(abbrev) <= 3, (
                f"Abbreviation {abbrev} should be 2–3 chars"
            )
            assert abbrev.isalpha(), (
                f"Abbreviation {abbrev} should be alphabetic"
            )

    def test_save_and_load_driver_map(self, tmp_path):
        """save_driver_map β†’ load_driver_map round-trip."""
        from data.build_driver_map import KNOWN_DRIVER_MAP

        # Save to temp file
        map_path = tmp_path / "drivers_map.json"
        with open(map_path, "w") as f:
            json.dump(KNOWN_DRIVER_MAP, f)

        # Load back
        with open(map_path, "r") as f:
            loaded = json.load(f)

        assert loaded == KNOWN_DRIVER_MAP


# ===================================================================
# Test: Jolpica Pipeline
# ===================================================================


class TestJolpicaPipeline:
    """Tests for data.jolpica_pipeline module."""

    def test_jolpica_get_returns_none_on_failure(self):
        """jolpica_get should return None after all retries fail."""
        from data.jolpica_pipeline import jolpica_get

        with patch("data.jolpica_pipeline.requests.get") as mock_get:
            mock_get.side_effect = requests.exceptions.RequestException("Connection error")
            result = jolpica_get(
                "https://fake.url", retries=1, base_delay=0.01
            )
            assert result is None

    def test_jolpica_get_succeeds_on_valid_response(self):
        """jolpica_get should return parsed JSON on success."""
        from data.jolpica_pipeline import jolpica_get

        mock_response = MagicMock()
        mock_response.json.return_value = {"MRData": {"test": True}}
        mock_response.raise_for_status.return_value = None

        with patch("data.jolpica_pipeline.requests.get", return_value=mock_response):
            with patch("data.jolpica_pipeline.time.sleep"):
                result = jolpica_get(
                    "https://fake.url", retries=1, base_delay=0.01
                )
                assert result == {"MRData": {"test": True}}

    def test_jolpica_get_retries_with_backoff(self):
        """jolpica_get should retry with exponential backoff."""
        from data.jolpica_pipeline import jolpica_get

        mock_response = MagicMock()
        mock_response.json.return_value = {"MRData": {}}
        mock_response.raise_for_status.return_value = None

        call_count = 0

        def side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise requests.exceptions.RequestException("Transient error")
            return mock_response

        with patch("data.jolpica_pipeline.requests.get", side_effect=side_effect):
            with patch("data.jolpica_pipeline.time.sleep"):
                result = jolpica_get(
                    "https://fake.url", retries=3, base_delay=0.01
                )
                assert result is not None
                assert call_count == 3

    def test_fetch_race_results_schema(self):
        """fetch_race_results should return correct column schema."""
        from data.jolpica_pipeline import fetch_race_results

        mock_data = {
            "MRData": {
                "RaceTable": {
                    "Races": [
                        {
                            "Circuit": {"circuitId": "monza"},
                            "Results": [
                                {
                                    "Driver": {
                                        "driverId": "max_verstappen",
                                        "givenName": "Max",
                                        "familyName": "Verstappen",
                                    },
                                    "Constructor": {"name": "Red Bull"},
                                    "grid": "1",
                                    "position": "1",
                                }
                            ],
                        }
                    ]
                }
            }
        }

        with patch("data.jolpica_pipeline.jolpica_get", return_value=mock_data):
            df = fetch_race_results(2023, 1)
            assert df is not None
            expected_cols = {
                "season", "round", "driver_id", "driver_name",
                "team", "grid_position", "finish_position", "circuit_id",
            }
            assert expected_cols.issubset(set(df.columns))

    def test_fetch_driver_standings_schema(self):
        """fetch_driver_standings should return driver_id + championship_standing."""
        from data.jolpica_pipeline import fetch_driver_standings

        mock_data = {
            "MRData": {
                "StandingsTable": {
                    "StandingsLists": [
                        {
                            "DriverStandings": [
                                {
                                    "position": "1",
                                    "Driver": {
                                        "driverId": "max_verstappen",
                                        "givenName": "Max",
                                        "familyName": "Verstappen",
                                    },
                                },
                                {
                                    "position": "2",
                                    "Driver": {
                                        "driverId": "lewis_hamilton",
                                        "givenName": "Lewis",
                                        "familyName": "Hamilton",
                                    },
                                },
                            ]
                        }
                    ]
                }
            }
        }

        with patch("data.jolpica_pipeline.jolpica_get", return_value=mock_data):
            df = fetch_driver_standings(2023, 5)
            assert df is not None
            assert "driver_id" in df.columns
            assert "championship_standing" in df.columns
            assert len(df) == 2


# ===================================================================
# Test: Merge Logic
# ===================================================================


class TestMergeLogic:
    """Tests for data.__init__ merge functions."""

    @pytest.fixture
    def sample_fastf1_df(self):
        """Create a sample FastF1-style DataFrame."""
        return pd.DataFrame(
            {
                "season": [2023, 2023, 2023, 2023],
                "round": [1, 1, 1, 1],
                "driver_id": ["VER", "HAM", "LEC", "NOR"],
                "driver_name": [
                    "Max Verstappen", "Lewis Hamilton",
                    "Charles Leclerc", "Lando Norris",
                ],
                "team": [
                    "Red Bull Racing", "Mercedes",
                    "Ferrari", "McLaren",
                ],
                "grid_position": [1, 4, 2, 5],
                "finish_position": [1, 3, 2, 4],
                "circuit_id": [
                    "Bahrain Grand Prix", "Bahrain Grand Prix",
                    "Bahrain Grand Prix", "Bahrain Grand Prix",
                ],
                "sector_1_time": [28.5, 28.8, 28.6, 28.9],
                "sector_2_time": [35.2, 35.5, 35.3, 35.6],
                "sector_3_time": [30.1, 30.4, 30.2, 30.5],
                "avg_lap_time_practice": [93.8, 94.7, 94.1, 95.0],
                "tire_compound": [0, 1, 0, 1],
                "tire_age_laps": [20, 25, 22, 28],
                "fresh_tire": [1, 1, 1, 0],
                "pit_stop_count": [2, 2, 3, 2],
                "team_pit_speed": [2.5, 2.8, 2.6, 2.9],
                "weather_temp_track": [45.0, 45.0, 45.0, 45.0],
                "weather_rainfall": [0, 0, 0, 0],
                "telemetry_available": [True, True, True, True],
            }
        )

    @pytest.fixture
    def sample_jolpica_df(self):
        """Create a sample Jolpica-style DataFrame."""
        return pd.DataFrame(
            {
                "season": [2016, 2016],
                "round": [1, 1],
                "driver_id": ["HAM", "ROS"],
                "driver_name": [
                    "Lewis Hamilton", "Nico Rosberg",
                ],
                "team": ["Mercedes", "Mercedes"],
                "grid_position": [1, 2],
                "finish_position": [2, 1],
                "circuit_id": ["albert_park", "albert_park"],
                "sector_1_time": [np.nan, np.nan],
                "sector_2_time": [np.nan, np.nan],
                "sector_3_time": [np.nan, np.nan],
                "avg_lap_time_practice": [np.nan, np.nan],
                "tire_compound": [np.nan, np.nan],
                "tire_age_laps": [np.nan, np.nan],
                "fresh_tire": [np.nan, np.nan],
                "pit_stop_count": [2, 1],
                "team_pit_speed": [np.nan, np.nan],
                "weather_temp_track": [np.nan, np.nan],
                "weather_rainfall": [np.nan, np.nan],
                "championship_standing": [1, 2],
                "telemetry_available": [False, False],
            }
        )

    @pytest.fixture
    def sample_lap_data(self):
        """Create sample lap data for safety car computation."""
        return pd.DataFrame(
            {
                "season": [2023] * 10,
                "round": [1] * 10,
                "driver_id": ["VER"] * 10,
                "lap_number": list(range(1, 11)),
                "track_status": ["1", "1", "4", "4", "1", "1", "1", "6", "1", "1"],
                "circuit_id": ["Bahrain Grand Prix"] * 10,
            }
        )

    def test_merge_produces_correct_columns(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """Merged dataset must have all required columns."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )

        required_cols = {
            "season", "round", "driver_id", "grid_position",
            "finish_position", "circuit_id", "telemetry_available",
            "regulation_era", "track_type", "driver_form_last3",
            "safety_car_probability", "win_probability",
        }
        assert required_cols.issubset(set(merged.columns)), (
            f"Missing columns: {required_cols - set(merged.columns)}"
        )

    def test_merge_row_count(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """Row count after merge should be sum of both DataFrames."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )
        expected = len(sample_fastf1_df) + len(sample_jolpica_df)
        assert len(merged) == expected

    def test_regulation_era_assignment(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """Regulation era should be correctly assigned by season."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )

        # 2016 β†’ hybrid_era
        era_2016 = merged[merged["season"] == 2016]["regulation_era"].unique()
        assert "hybrid_era" in era_2016

        # 2023 β†’ ground_effect_era
        era_2023 = merged[merged["season"] == 2023]["regulation_era"].unique()
        assert "ground_effect_era" in era_2023

    def test_win_probability_target(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """win_probability should be 1 only for P1 finishes."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )

        p1_rows = merged[merged["finish_position"] == 1]
        non_p1_rows = merged[merged["finish_position"] != 1]

        assert (p1_rows["win_probability"] == 1).all()
        assert (non_p1_rows["win_probability"] == 0).all()

    def test_telemetry_flag_integrity(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """telemetry_available must be True for FastF1, False for Jolpica."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )

        fastf1_rows = merged[merged["season"] >= 2018]
        jolpica_rows = merged[merged["season"] <= 2017]

        assert fastf1_rows["telemetry_available"].all()
        assert not jolpica_rows["telemetry_available"].any()

    def test_sorted_by_season_round(
        self, sample_fastf1_df, sample_jolpica_df, sample_lap_data
    ):
        """Merged dataset must be sorted by (season, round, grid_position)."""
        from data import merge_datasets

        merged = merge_datasets(
            sample_fastf1_df, sample_jolpica_df, sample_lap_data
        )

        # Check season is non-decreasing
        assert (merged["season"].diff().dropna() >= 0).all()

    def test_safety_car_probability_computed(self, sample_lap_data):
        """safety_car_probability should be computed from lap data."""
        from data import compute_safety_car_probability

        sc_df = compute_safety_car_probability(sample_lap_data)
        assert len(sc_df) == 1  # One circuit
        assert "safety_car_probability" in sc_df.columns

        # 2 SC laps out of 10 β†’ 0.2
        prob = sc_df.iloc[0]["safety_car_probability"]
        assert prob == pytest.approx(0.2, abs=0.01)

    def test_track_type_mapping(self):
        """_get_track_type should correctly classify circuits."""
        from data import _get_track_type

        assert _get_track_type("Monaco Grand Prix") == "street"
        assert _get_track_type("Singapore Grand Prix") == "street"
        assert _get_track_type("Australian Grand Prix") == "hybrid"
        assert _get_track_type("Silverstone Grand Prix") == "permanent"


# ===================================================================
# Test: FastF1 Pipeline (unit tests with mocking)
# ===================================================================


class TestFastF1Pipeline:
    """Tests for data.fastf1_pipeline module β€” mocked to avoid real API calls."""

    def test_enable_cache_creates_directory(self, tmp_path):
        """enable_cache should create the cache directory."""
        from data.fastf1_pipeline import enable_cache

        cache_dir = tmp_path / "test_cache"
        with patch("data.fastf1_pipeline.fastf1.Cache.enable_cache"):
            enable_cache(str(cache_dir))
        assert cache_dir.exists()

    def test_build_season_returns_tuple(self):
        """build_season_dataframe should return (race_df, lap_data_df) tuple."""
        from data.fastf1_pipeline import build_season_dataframe

        # Mock schedule to return empty (no events)
        with patch("data.fastf1_pipeline.fastf1.get_event_schedule") as mock_sched:
            mock_sched.side_effect = Exception("No schedule")
            with patch("data.fastf1_pipeline.enable_cache"):
                result = build_season_dataframe(2023)
                assert isinstance(result, tuple)
                assert len(result) == 2


# ===================================================================
# Run
# ===================================================================

if __name__ == "__main__":
    pytest.main([__file__, "-v", "--tb=short"])