NiketKakkar commited on
Commit
97e14f1
·
1 Parent(s): ccecc26

sync all project files

Browse files
app.py CHANGED
@@ -201,12 +201,11 @@ F1_CSS = """
201
 
202
  #race-topbar {
203
  margin-top: 14px;
204
- background: rgba(23, 23, 23, 0.88);
205
  border: 1px solid var(--f1-line);
206
  border-left: 4px solid var(--f1-accent);
207
  border-radius: 12px;
208
  padding: 16px;
209
- backdrop-filter: blur(18px);
210
  }
211
 
212
  #race-topbar label,
 
201
 
202
  #race-topbar {
203
  margin-top: 14px;
204
+ background: rgba(23, 23, 23, 0.95);
205
  border: 1px solid var(--f1-line);
206
  border-left: 4px solid var(--f1-accent);
207
  border-radius: 12px;
208
  padding: 16px;
 
209
  }
210
 
211
  #race-topbar label,
tests/fixtures/2023_British_GP_laps.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ff15e331c4f3a295b46144d5af6b7a15daa5a56917d44fd20173dbe67d79f25a
3
+ size 9641
tests/fixtures/make_fixtures.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic Parquet fixtures for race_data unit tests.
3
+ Run once: python tests/fixtures/make_fixtures.py
4
+ """
5
+
6
+ from pathlib import Path
7
+ import pandas as pd
8
+ import numpy as np
9
+
10
+ OUT_DIR = Path(__file__).parent
11
+ LAPS_COLUMNS = [
12
+ "lap_number", "driver_code", "team", "lap_time_s", "compound",
13
+ "tyre_life", "position", "gap_to_leader_s", "pit_in", "pit_out", "sc_active",
14
+ ]
15
+
16
+ DRIVERS = ["VER", "HAM", "NOR", "PER", "ALO"]
17
+ TOTAL_LAPS = 52
18
+
19
+
20
+ def _make_laps() -> pd.DataFrame:
21
+ rows = []
22
+ for lap in range(1, TOTAL_LAPS + 1):
23
+ for i, drv in enumerate(DRIVERS):
24
+ rows.append({
25
+ "lap_number": lap,
26
+ "driver_code": drv,
27
+ "team": "RBR" if drv in ("VER", "PER") else "MER",
28
+ "lap_time_s": 90.0 + i * 0.3 + np.random.uniform(-0.1, 0.1),
29
+ "compound": "MEDIUM" if lap <= 25 else "HARD",
30
+ "tyre_life": lap if lap <= 25 else lap - 25,
31
+ "position": i + 1,
32
+ "gap_to_leader_s": 0.0 if i == 0 else i * 2.5,
33
+ "pit_in": lap == 26 and i == 0,
34
+ "pit_out": lap == 27 and i == 0,
35
+ "sc_active": lap in (10, 11, 12),
36
+ })
37
+ df = pd.DataFrame(rows, columns=LAPS_COLUMNS)
38
+ df["lap_number"] = df["lap_number"].astype("int32")
39
+ df["tyre_life"] = df["tyre_life"].astype("int32")
40
+ df["position"] = df["position"].astype("int32")
41
+ return df
42
+
43
+
44
+ if __name__ == "__main__":
45
+ laps = _make_laps()
46
+ path = OUT_DIR / "2023_British_GP_laps.parquet"
47
+ laps.to_parquet(path, index=False)
48
+ print(f"Written {len(laps)} rows to {path}")
tests/smoke/test_modal_client.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for the Modal client (Issue #8).
2
+
3
+ MANUAL-ONLY — not run in CI.
4
+
5
+ Run with:
6
+ python -m pytest tests/smoke/test_modal_client.py -v
7
+
8
+ WARNING: Each invocation hits live Modal containers and consumes Modal credits.
9
+ Do not add this to CI or run repeatedly without need.
10
+ """
11
+
12
+ import time
13
+
14
+ from modal_backend.client import call_generate_commentary, call_reason_strategy
15
+
16
+ COMMENTARY_PROMPT = (
17
+ "LAP 47 of 57 at Monaco. Verstappen leads Hamilton by 6.2 seconds. "
18
+ "Hamilton is on worn mediums, tyre age 28 laps. "
19
+ "Generate a 2-sentence broadcast commentary update."
20
+ )
21
+
22
+ STRATEGY_PROMPT = (
23
+ "Race: 2023 British Grand Prix\n"
24
+ "Original outcome: Verstappen won from pole, Hamilton finished P4 after a late pit.\n"
25
+ "User change: What if Hamilton had pitted 5 laps earlier on lap 30 for fresh mediums?"
26
+ )
27
+
28
+
29
+ def test_container1_reachable_and_non_empty():
30
+ """Container 1 (MiniCPM-o) returns a non-empty text response."""
31
+ result = call_generate_commentary(prompt=COMMENTARY_PROMPT)
32
+ assert isinstance(result, dict), "Expected dict response from Container 1"
33
+ assert result.get("text"), "Container 1 returned empty text"
34
+
35
+
36
+ def test_container2_reachable_and_non_empty():
37
+ """Container 2 (Nemotron Nano) returns a non-empty reasoning chain."""
38
+ result = call_reason_strategy(prompt=STRATEGY_PROMPT)
39
+ assert isinstance(result, dict), "Expected dict response from Container 2"
40
+ assert result.get("reasoning_chain"), "Container 2 returned empty reasoning_chain"
41
+
42
+
43
+ def test_warmup_returns_under_2_seconds():
44
+ """Warmup flag (both containers already warm) completes in under 2 seconds."""
45
+ start = time.monotonic()
46
+ call_generate_commentary(prompt="", warmup=True)
47
+ call_reason_strategy(prompt="", warmup=True)
48
+ elapsed = time.monotonic() - start
49
+ assert elapsed < 2.0, f"Warmup took {elapsed:.2f}s — containers may be cold"
tests/test_what_if_tab.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the What-If tab logic in app.py."""
2
+
3
+ import math
4
+ import types
5
+ import unittest
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pandas as pd
9
+ import sys
10
+ import os
11
+
12
+ # Ensure project root is on path
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
14
+
15
+
16
+ # ── helpers imported directly (no Gradio or Modal needed) ──────────────────
17
+ from app import (
18
+ _build_timing_table,
19
+ _build_strategy_prompt,
20
+ _run_what_if,
21
+ _PIVOT_LAP_MIN,
22
+ _PIVOT_LAP_MAX,
23
+ )
24
+
25
+
26
+ def _make_window(n_rows=3) -> pd.DataFrame:
27
+ return pd.DataFrame(
28
+ {
29
+ "lap_number": [30, 30, 31],
30
+ "driver_code": ["VER", "HAM", "VER"],
31
+ "position": [1, 2, 1],
32
+ "gap_to_leader_s": [float("nan"), 3.412, float("nan")],
33
+ "compound": ["MEDIUM", "HARD", "MEDIUM"],
34
+ "tyre_life": [10, 14, 11],
35
+ "lap_time_s": [88.1, 88.9, 88.0],
36
+ "sc_active": [False, False, False],
37
+ }
38
+ )
39
+
40
+
41
+ class TestBuildTimingTable(unittest.TestCase):
42
+ def test_header_row_present(self):
43
+ table = _build_timing_table(_make_window())
44
+ assert "LAP" in table
45
+ assert "DRV" in table
46
+ assert "CMPD" in table
47
+
48
+ def test_leader_label(self):
49
+ table = _build_timing_table(_make_window())
50
+ assert "LEADER" in table
51
+
52
+ def test_gap_formatted(self):
53
+ table = _build_timing_table(_make_window())
54
+ assert "3.412" in table
55
+
56
+ def test_empty_dataframe(self):
57
+ result = _build_timing_table(pd.DataFrame())
58
+ assert "No data" in result
59
+
60
+
61
+ class TestBuildStrategyPrompt(unittest.TestCase):
62
+ def _race(self):
63
+ return {
64
+ "name": "British Grand Prix",
65
+ "season": 2023,
66
+ "circuit": "Silverstone",
67
+ "top_finishers": "Verstappen, Hamilton, Alonso",
68
+ }
69
+
70
+ def test_fields_injected(self):
71
+ table = "LAP DRV POS GAP(s) CMPD AGE"
72
+ prompt = _build_strategy_prompt(self._race(), 35, "What if HAM pitted earlier?", table)
73
+ assert "British Grand Prix" in prompt
74
+ assert "2023" in prompt
75
+ assert "Silverstone" in prompt
76
+ assert "35" in prompt
77
+ assert "What if HAM pitted earlier?" in prompt
78
+ assert table in prompt
79
+
80
+
81
+ class TestRunWhatIf(unittest.TestCase):
82
+ """Test the generator function _run_what_if without hitting Modal."""
83
+
84
+ def _collect(self, gen):
85
+ return list(gen)
86
+
87
+ def test_empty_whatif_returns_prompt(self):
88
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
89
+ results = self._collect(_run_what_if(race, 30, " "))
90
+ left, right = results[0]
91
+ assert "Enter" in left
92
+
93
+ def test_pivot_below_min_shows_warning(self):
94
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
95
+ results = self._collect(_run_what_if(race, _PIVOT_LAP_MIN - 1, "some scenario"))
96
+ left, right = results[0]
97
+ assert "outside the recommended range" in left
98
+ assert right == ""
99
+
100
+ def test_pivot_above_max_shows_warning(self):
101
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
102
+ results = self._collect(_run_what_if(race, _PIVOT_LAP_MAX + 1, "some scenario"))
103
+ left, right = results[0]
104
+ assert "outside the recommended range" in left
105
+ assert right == ""
106
+
107
+ def test_pivot_at_boundary_passes_validation(self):
108
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
109
+ window_df = _make_window()
110
+ with patch("app.get_race_window", return_value=window_df), \
111
+ patch("app.call_reason_strategy", return_value={"reasoning_chain": "Step 1. Step 2."}):
112
+ results = self._collect(_run_what_if(race, _PIVOT_LAP_MIN, "HAM earlier pit?"))
113
+ # last yield should contain reasoning in right panel
114
+ final_left, final_right = results[-1]
115
+ assert "Step 1" in final_right
116
+
117
+ def test_file_not_found_surfaces_error(self):
118
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
119
+ with patch("app.get_race_window", side_effect=FileNotFoundError("No cache")):
120
+ results = self._collect(_run_what_if(race, 30, "some scenario"))
121
+ # first yield is loading, second is error
122
+ left, right = results[-1]
123
+ assert "No cache" in left
124
+
125
+ def test_timing_table_in_left_panel_on_success(self):
126
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
127
+ window_df = _make_window()
128
+ with patch("app.get_race_window", return_value=window_df), \
129
+ patch("app.call_reason_strategy", return_value={"reasoning_chain": "Analysis done."}):
130
+ results = self._collect(_run_what_if(race, 30, "What if VER pitted?"))
131
+ final_left, final_right = results[-1]
132
+ assert "LAP" in final_left
133
+ assert "Analysis done." in final_right
134
+
135
+ def test_no_audio_component(self):
136
+ """Right panel is plain text — no audio output from _run_what_if."""
137
+ race = {"season": 2023, "round": 5, "name": "X", "circuit": "Y"}
138
+ window_df = _make_window()
139
+ with patch("app.get_race_window", return_value=window_df), \
140
+ patch("app.call_reason_strategy", return_value={"reasoning_chain": "Reasoning."}):
141
+ results = self._collect(_run_what_if(race, 30, "What if?"))
142
+ for left, right in results:
143
+ # right panel is always a string, never bytes/audio
144
+ assert isinstance(right, str)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ unittest.main()
tests/unit/test_commentary_tab.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Commentary tab handler (Issue #10)."""
2
+
3
+ import sys
4
+ import types
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import patch, MagicMock
8
+
9
+ import pandas as pd
10
+ import numpy as np
11
+ import pytest
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers / fixtures
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @pytest.fixture()
21
+ def lap_df():
22
+ rows = []
23
+ for lap in range(15, 26):
24
+ for driver, pos, team, gap in [
25
+ ("VER", 1, "Red Bull Racing", 0.0),
26
+ ("NOR", 2, "McLaren", 4.5),
27
+ ]:
28
+ rows.append({
29
+ "lap_number": lap,
30
+ "driver_code": driver,
31
+ "team": team,
32
+ "position": pos,
33
+ "gap_to_leader_s": gap,
34
+ "compound": "MEDIUM",
35
+ "tyre_life": lap - 10,
36
+ "lap_time_s": 90.1,
37
+ "sc_active": False,
38
+ })
39
+ return pd.DataFrame(rows)
40
+
41
+
42
+ @pytest.fixture()
43
+ def race():
44
+ return {"season": 2024, "round": 21, "name": "Brazilian GP", "circuit": "Interlagos", "modes": ["commentary"]}
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # _top_two_drivers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ class TestTopTwoDrivers:
52
+ def test_returns_p1_p2_at_pivot_lap(self, lap_df):
53
+ with patch("app._CACHE_DIR") as mock_cache, \
54
+ patch("app._race_key", return_value="2024_Brazilian_GP"), \
55
+ patch("pandas.read_parquet", return_value=lap_df):
56
+ from app import _top_two_drivers
57
+ driver_a, driver_b, team_name = _top_two_drivers(2024, 21, 20)
58
+ assert driver_a == "VER"
59
+ assert driver_b == "NOR"
60
+ assert team_name == "Red Bull Racing"
61
+
62
+ def test_falls_back_to_last_lap_when_pivot_missing(self, lap_df):
63
+ # pivot_lap=99 doesn't exist — should fall back to lap 25
64
+ with patch("app._race_key", return_value="2024_Brazilian_GP"), \
65
+ patch("pandas.read_parquet", return_value=lap_df):
66
+ from app import _top_two_drivers
67
+ driver_a, driver_b, team_name = _top_two_drivers(2024, 21, 99)
68
+ assert driver_a == "VER"
69
+ assert driver_b == "NOR"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # _generate_commentary — mocked Modal call
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class TestGenerateCommentary:
77
+ def _make_wav_bytes(self):
78
+ """Minimal valid 16-bit WAV bytes (44-byte header + 2 bytes of silence)."""
79
+ import struct
80
+ data = b"\x00\x00"
81
+ header = struct.pack(
82
+ "<4sI4s4sIHHIIHH4sI",
83
+ b"RIFF", 36 + len(data), b"WAVE",
84
+ b"fmt ", 16, 1, 1, 24000, 48000, 2, 16,
85
+ b"data", len(data),
86
+ )
87
+ return header + data
88
+
89
+ def test_broadcast_returns_audio_path_and_text(self, race, lap_df):
90
+ audio_bytes = self._make_wav_bytes()
91
+ modal_result = {"text": "Verstappen extends his lead.", "audio_wav": audio_bytes}
92
+
93
+ with patch("app._top_two_drivers", return_value=("VER", "NOR", "Red Bull Racing")), \
94
+ patch("app.get_lap_window", return_value=lap_df), \
95
+ patch("app.build_commentary_prompt", return_value="[prompt]"), \
96
+ patch("app.call_generate_commentary", return_value=modal_result):
97
+ from app import _generate_commentary
98
+ audio_path, text = _generate_commentary(race, 20, "Broadcast")
99
+
100
+ assert text == "Verstappen extends his lead."
101
+ assert audio_path is not None
102
+ assert audio_path.endswith(".wav")
103
+
104
+ def test_radio_passes_mode_radio_to_prompt_builder(self, race, lap_df):
105
+ modal_result = {"text": "Box this lap.", "audio_wav": b""}
106
+
107
+ with patch("app._top_two_drivers", return_value=("VER", "NOR", "Red Bull Racing")), \
108
+ patch("app.get_lap_window", return_value=lap_df), \
109
+ patch("app.build_commentary_prompt", return_value="[prompt]") as mock_build, \
110
+ patch("app.call_generate_commentary", return_value=modal_result):
111
+ from app import _generate_commentary
112
+ _generate_commentary(race, 20, "Radio")
113
+
114
+ mock_build.assert_called_once_with(lap_df, "Red Bull Racing", "radio")
115
+
116
+ def test_broadcast_passes_mode_broadcast_to_prompt_builder(self, race, lap_df):
117
+ modal_result = {"text": "Hamilton closes in.", "audio_wav": b""}
118
+
119
+ with patch("app._top_two_drivers", return_value=("VER", "HAM", "Red Bull Racing")), \
120
+ patch("app.get_lap_window", return_value=lap_df), \
121
+ patch("app.build_commentary_prompt", return_value="[prompt]") as mock_build, \
122
+ patch("app.call_generate_commentary", return_value=modal_result):
123
+ from app import _generate_commentary
124
+ _generate_commentary(race, 20, "Broadcast")
125
+
126
+ mock_build.assert_called_once_with(lap_df, "Red Bull Racing", "broadcast")
127
+
128
+ def test_no_audio_bytes_returns_none_path(self, race, lap_df):
129
+ modal_result = {"text": "VER leads.", "audio_wav": b""}
130
+
131
+ with patch("app._top_two_drivers", return_value=("VER", "NOR", "Red Bull Racing")), \
132
+ patch("app.get_lap_window", return_value=lap_df), \
133
+ patch("app.build_commentary_prompt", return_value="[prompt]"), \
134
+ patch("app.call_generate_commentary", return_value=modal_result):
135
+ from app import _generate_commentary
136
+ audio_path, text = _generate_commentary(race, 20, "Broadcast")
137
+
138
+ assert audio_path is None
139
+ assert text == "VER leads."
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Two-tier loading copy
144
+ # ---------------------------------------------------------------------------
145
+
146
+ class TestLoadingMessages:
147
+ def test_first_call_returns_pit_wall_message(self):
148
+ from modal_backend.client import get_commentary_loading_message
149
+ import modal_backend.client as client_mod
150
+ original = client_mod._commentary_call_count
151
+ try:
152
+ client_mod._commentary_call_count = 0
153
+ msg = get_commentary_loading_message()
154
+ assert "pit wall" in msg.lower()
155
+ finally:
156
+ client_mod._commentary_call_count = original
157
+
158
+ def test_subsequent_call_returns_shorter_message(self):
159
+ from modal_backend.client import get_commentary_loading_message
160
+ import modal_backend.client as client_mod
161
+ original = client_mod._commentary_call_count
162
+ try:
163
+ client_mod._commentary_call_count = 1
164
+ msg = get_commentary_loading_message()
165
+ assert msg != "Connecting to the pit wall… (~20s first call)"
166
+ finally:
167
+ client_mod._commentary_call_count = original
tests/unit/test_persona.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the persona module (Issue #6)."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+
6
+ # Allow imports from the project root
7
+ import sys
8
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
9
+
10
+ from prompts.persona import get_system_prompt, _driver_file
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Helpers
15
+ # ---------------------------------------------------------------------------
16
+
17
+ def _read_driver_file(name: str) -> str:
18
+ path = _driver_file(name)
19
+ return path.read_text(encoding="utf-8")
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Knowledge cutoff assertions
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class TestCutoffYear:
27
+ def test_verstappen_cutoff_present(self):
28
+ text = _read_driver_file("verstappen")
29
+ assert "end of 2023" in text.lower() or "2023" in text
30
+
31
+ def test_hamilton_cutoff_present(self):
32
+ text = _read_driver_file("hamilton")
33
+ assert "end of 2023" in text.lower() or "2023" in text
34
+
35
+ def test_norris_cutoff_present(self):
36
+ text = _read_driver_file("norris")
37
+ assert "end of 2023" in text.lower() or "2023" in text
38
+
39
+ def test_schumacher_cutoff_is_2012(self):
40
+ text = _read_driver_file("schumacher")
41
+ assert "2012" in text
42
+ assert "end of 2012" in text.lower()
43
+
44
+ def test_senna_cutoff_is_may_1994(self):
45
+ text = _read_driver_file("senna")
46
+ assert "1994" in text
47
+ assert "may 1994" in text.lower()
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Schumacher content guard — no forbidden words/years
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class TestSchumacherContentGuard:
55
+ def setup_method(self):
56
+ self.text = _read_driver_file("schumacher").lower()
57
+
58
+ def test_no_word_accident(self):
59
+ assert "accident" not in self.text
60
+
61
+ def test_no_word_skiing(self):
62
+ assert "skiing" not in self.text
63
+
64
+ def test_no_year_2013(self):
65
+ assert "2013" not in self.text
66
+
67
+ def test_no_years_after_2012(self):
68
+ for year in range(2013, 2030):
69
+ assert str(year) not in self.text, f"Forbidden year {year} found in schumacher.txt"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Deflection phrases — non-empty and count
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class TestDeflectionPhrases:
77
+ @pytest.mark.parametrize("driver", ["verstappen", "hamilton", "norris", "senna", "schumacher"])
78
+ def test_deflection_section_present(self, driver):
79
+ text = _read_driver_file(driver)
80
+ assert "deflection phrase" in text.lower(), f"{driver}: missing deflection phrases section"
81
+
82
+ @pytest.mark.parametrize("driver", ["verstappen", "hamilton", "norris", "senna", "schumacher"])
83
+ def test_at_least_five_deflection_phrases(self, driver):
84
+ text = _read_driver_file(driver)
85
+ # Deflection phrases are numbered list items 1-5
86
+ count = sum(1 for line in text.splitlines() if line.strip() and line.strip()[0].isdigit() and ". " in line[:4])
87
+ assert count >= 5, f"{driver}: expected at least 5 numbered deflection phrases, found {count}"
88
+
89
+ @pytest.mark.parametrize("driver", ["verstappen", "hamilton", "norris", "senna", "schumacher"])
90
+ def test_deflection_phrases_non_empty(self, driver):
91
+ text = _read_driver_file(driver)
92
+ lines = [l.strip() for l in text.splitlines() if l.strip() and l.strip()[0].isdigit() and ". " in l.strip()[:4]]
93
+ for line in lines:
94
+ # Strip leading "1. " etc. and check there's real content
95
+ content = line.split(". ", 1)[-1].strip()
96
+ assert len(content) > 10, f"{driver}: deflection phrase too short: {repr(line)}"
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Personality distinctiveness — spot-check key register words
101
+ # ---------------------------------------------------------------------------
102
+
103
+ class TestPersonalityRegister:
104
+ def test_verstappen_blunt_register(self):
105
+ text = _read_driver_file("verstappen").lower()
106
+ assert "blunt" in text or "direct" in text
107
+
108
+ def test_hamilton_diplomatic_register(self):
109
+ text = _read_driver_file("hamilton").lower()
110
+ assert "diplomatic" in text or "articulate" in text or "considered" in text
111
+
112
+ def test_norris_self_deprecating_register(self):
113
+ text = _read_driver_file("norris").lower()
114
+ assert "self-deprecating" in text or "self deprecating" in text
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Persona module: get_system_prompt
119
+ # ---------------------------------------------------------------------------
120
+
121
+ class TestGetSystemPrompt:
122
+ def test_returns_string_for_all_drivers(self):
123
+ for driver in ["verstappen", "hamilton", "norris", "senna", "schumacher"]:
124
+ result = get_system_prompt(driver)
125
+ assert isinstance(result, str)
126
+ assert len(result) > 100
127
+
128
+ def test_case_insensitive_driver_name(self):
129
+ lower = get_system_prompt("verstappen")
130
+ upper = get_system_prompt("Verstappen")
131
+ mixed = get_system_prompt("VERSTAPPEN")
132
+ assert lower == upper == mixed
133
+
134
+ def test_missing_driver_raises_file_not_found(self):
135
+ with pytest.raises(FileNotFoundError) as exc_info:
136
+ get_system_prompt("alonso")
137
+ assert "alonso" in str(exc_info.value).lower()
138
+ assert "available drivers" in str(exc_info.value).lower()
139
+
140
+ def test_missing_driver_error_is_descriptive(self):
141
+ with pytest.raises(FileNotFoundError) as exc_info:
142
+ get_system_prompt("grosjean")
143
+ msg = str(exc_info.value)
144
+ # Must name the bad driver and hint at valid ones
145
+ assert "grosjean" in msg.lower()
146
+ assert any(d in msg for d in ["verstappen", "hamilton", "norris"])
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Race context injection
151
+ # ---------------------------------------------------------------------------
152
+
153
+ class TestRaceContextInjection:
154
+ RACE = "2023 Dutch Grand Prix, Lap 42 of 72. Verstappen leads by 4.8s on hard tires, age 31."
155
+
156
+ def test_race_context_injected_for_verstappen(self):
157
+ result = get_system_prompt("verstappen", race_context=self.RACE)
158
+ assert self.RACE in result
159
+ assert "Current Race Context" in result
160
+
161
+ def test_race_context_injected_for_hamilton(self):
162
+ result = get_system_prompt("hamilton", race_context=self.RACE)
163
+ assert self.RACE in result
164
+
165
+ def test_race_context_injected_for_norris(self):
166
+ result = get_system_prompt("norris", race_context=self.RACE)
167
+ assert self.RACE in result
168
+
169
+ def test_race_context_silently_absent_for_senna(self):
170
+ result = get_system_prompt("senna", race_context=self.RACE)
171
+ # No error raised, race context not injected
172
+ assert self.RACE not in result
173
+ assert "Current Race Context" not in result
174
+
175
+ def test_race_context_silently_absent_for_schumacher(self):
176
+ result = get_system_prompt("schumacher", race_context=self.RACE)
177
+ assert self.RACE not in result
178
+ assert "Current Race Context" not in result
179
+
180
+ def test_none_race_context_active_driver_no_injection(self):
181
+ result = get_system_prompt("verstappen", race_context=None)
182
+ assert "Current Race Context" not in result
183
+
184
+ def test_empty_race_context_treated_as_absent(self):
185
+ result = get_system_prompt("verstappen", race_context="")
186
+ assert "Current Race Context" not in result
tests/unit/test_persona_chat.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for Issue #12 — Persona Chat tab logic.
2
+
3
+ Tests cover:
4
+ - Driver selector: correct set of 5 drivers
5
+ - Historical vs active driver classification
6
+ - Race context injection (active drivers only)
7
+ - Persona prompt construction via build_persona_prompt
8
+ - Knowledge cutoff enforcement (deflection phrases present)
9
+ - on_driver_selected: historical notice visibility and race context display
10
+ - on_race_changed: active vs historical behavior
11
+ - on_send: empty input guard, system prompt construction
12
+ - transcribe_audio: raises EnvironmentError when COHERE_API_KEY is missing
13
+ - call_persona_chat: delegates to generate_commentary with merged prompt
14
+ """
15
+
16
+ import io
17
+ import sys
18
+ from pathlib import Path
19
+ from unittest.mock import MagicMock, patch
20
+
21
+ import pytest
22
+
23
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
24
+
25
+ from app import (
26
+ PERSONA_DRIVERS,
27
+ _ACTIVE_DRIVERS,
28
+ _HISTORICAL_DRIVERS,
29
+ _race_context_string,
30
+ )
31
+ from prompts.builder import build_persona_prompt
32
+
33
+
34
+ # ── Fixtures ─────────────────────────────────────────────────────────────────
35
+
36
+ SAMPLE_RACE = {
37
+ "season": 2023,
38
+ "name": "British Grand Prix",
39
+ "circuit": "Silverstone",
40
+ "round": 10,
41
+ }
42
+
43
+
44
+ # ── Driver list ───────────────────────────────────────────────────────────────
45
+
46
+ class TestDriverList:
47
+ def test_five_drivers_defined(self):
48
+ assert len(PERSONA_DRIVERS) == 5
49
+
50
+ def test_expected_drivers_present(self):
51
+ names = {d.lower() for d in PERSONA_DRIVERS}
52
+ assert names == {"verstappen", "hamilton", "norris", "senna", "schumacher"}
53
+
54
+ def test_active_and_historical_sets_are_disjoint(self):
55
+ assert _ACTIVE_DRIVERS.isdisjoint(_HISTORICAL_DRIVERS)
56
+
57
+ def test_all_drivers_classified(self):
58
+ all_classified = _ACTIVE_DRIVERS | _HISTORICAL_DRIVERS
59
+ for driver in PERSONA_DRIVERS:
60
+ assert driver.lower() in all_classified, f"{driver} not classified"
61
+
62
+
63
+ # ── Race context string ───────────────────────────────────────────────────────
64
+
65
+ class TestRaceContextString:
66
+ def test_includes_season(self):
67
+ result = _race_context_string(SAMPLE_RACE)
68
+ assert "2023" in result
69
+
70
+ def test_includes_race_name(self):
71
+ result = _race_context_string(SAMPLE_RACE)
72
+ assert "British Grand Prix" in result
73
+
74
+ def test_includes_circuit(self):
75
+ result = _race_context_string(SAMPLE_RACE)
76
+ assert "Silverstone" in result
77
+
78
+ def test_includes_round(self):
79
+ result = _race_context_string(SAMPLE_RACE)
80
+ assert "10" in result
81
+
82
+ def test_returns_string(self):
83
+ assert isinstance(_race_context_string(SAMPLE_RACE), str)
84
+
85
+
86
+ # ── on_driver_selected logic ──────────────────────────────────────────────────
87
+
88
+ class TestDriverSelectedLogic:
89
+ """Tests for the on_driver_selected callback logic (extracted inline)."""
90
+
91
+ def _run(self, driver: str, race: dict):
92
+ key = driver.lower()
93
+ is_historical = key in _HISTORICAL_DRIVERS
94
+ if is_historical:
95
+ notice = "Historical drivers don't use race telemetry"
96
+ ctx_display = ""
97
+ else:
98
+ notice = ""
99
+ ctx_display = _race_context_string(race) if race else ""
100
+ return is_historical, notice, ctx_display
101
+
102
+ def test_senna_is_historical(self):
103
+ is_h, notice, ctx = self._run("Senna", SAMPLE_RACE)
104
+ assert is_h is True
105
+ assert notice != ""
106
+ assert ctx == ""
107
+
108
+ def test_schumacher_is_historical(self):
109
+ is_h, notice, ctx = self._run("Schumacher", SAMPLE_RACE)
110
+ assert is_h is True
111
+ assert ctx == ""
112
+
113
+ def test_verstappen_is_active(self):
114
+ is_h, notice, ctx = self._run("Verstappen", SAMPLE_RACE)
115
+ assert is_h is False
116
+ assert notice == ""
117
+ assert "2023" in ctx
118
+
119
+ def test_hamilton_active_gets_race_context(self):
120
+ is_h, notice, ctx = self._run("Hamilton", SAMPLE_RACE)
121
+ assert "British Grand Prix" in ctx
122
+
123
+ def test_norris_active_gets_race_context(self):
124
+ is_h, notice, ctx = self._run("Norris", SAMPLE_RACE)
125
+ assert "Silverstone" in ctx
126
+
127
+
128
+ # ── on_race_changed logic ─────────────────────────────────────────────────────
129
+
130
+ class TestRaceChangedLogic:
131
+ def _run(self, race: dict, driver: str) -> str:
132
+ if driver.lower() in _HISTORICAL_DRIVERS:
133
+ return ""
134
+ return _race_context_string(race) if race else ""
135
+
136
+ def test_active_driver_returns_race_context(self):
137
+ result = self._run(SAMPLE_RACE, "Verstappen")
138
+ assert "British Grand Prix" in result
139
+
140
+ def test_historical_driver_returns_empty(self):
141
+ result = self._run(SAMPLE_RACE, "Senna")
142
+ assert result == ""
143
+
144
+ def test_schumacher_returns_empty(self):
145
+ result = self._run(SAMPLE_RACE, "Schumacher")
146
+ assert result == ""
147
+
148
+
149
+ # ── on_send guard: empty input ────────────────────────────────────────────────
150
+
151
+ class TestOnSendEmptyInputGuard:
152
+ def _send_results(self, driver, user_text, race):
153
+ # Reproduce the empty-check from on_send
154
+ if not user_text or not user_text.strip():
155
+ return [("Please record or type a question first.", None)]
156
+ return []
157
+
158
+ def test_empty_string_triggers_guard(self):
159
+ results = self._send_results("Verstappen", "", SAMPLE_RACE)
160
+ assert results
161
+ assert "Please record or type a question first." in results[0][0]
162
+
163
+ def test_whitespace_triggers_guard(self):
164
+ results = self._send_results("Verstappen", " ", SAMPLE_RACE)
165
+ assert results
166
+ assert results[0][1] is None
167
+
168
+ def test_none_triggers_guard(self):
169
+ results = self._send_results("Verstappen", None, SAMPLE_RACE)
170
+ assert results
171
+
172
+
173
+ # ── Persona prompt construction ───────────────────────────────────────────────
174
+
175
+ class TestPersonaPromptConstruction:
176
+ def test_active_driver_receives_race_context(self):
177
+ ctx = _race_context_string(SAMPLE_RACE)
178
+ prompt = build_persona_prompt("verstappen", race_context=ctx)
179
+ assert "British Grand Prix" in prompt
180
+ assert "Current Race Context" in prompt
181
+
182
+ def test_historical_driver_no_race_context(self):
183
+ ctx = _race_context_string(SAMPLE_RACE)
184
+ prompt = build_persona_prompt("senna", race_context=ctx)
185
+ assert "Current Race Context" not in prompt
186
+
187
+ def test_cutoff_block_present_for_all_drivers(self):
188
+ for driver in ["verstappen", "hamilton", "norris", "senna", "schumacher"]:
189
+ prompt = build_persona_prompt(driver)
190
+ assert "Knowledge Cutoff" in prompt, f"{driver}: missing cutoff block"
191
+
192
+ def test_senna_prompt_does_not_contain_post_1994_events(self):
193
+ prompt = build_persona_prompt("senna").lower()
194
+ assert "may 1994" in prompt or "1994" in prompt
195
+
196
+ def test_schumacher_prompt_does_not_mention_accident(self):
197
+ prompt = build_persona_prompt("schumacher").lower()
198
+ assert "accident" not in prompt
199
+ assert "skiing" not in prompt
200
+
201
+
202
+ # ── Knowledge cutoff enforcement ──────────────────────────────────────────────
203
+
204
+ class TestKnowledgeCutoffEnforcement:
205
+ """Deflection phrases must be present for all drivers (enforced by persona files)."""
206
+
207
+ @pytest.mark.parametrize("driver", ["verstappen", "hamilton", "norris", "senna", "schumacher"])
208
+ def test_deflection_section_in_prompt(self, driver):
209
+ prompt = build_persona_prompt(driver)
210
+ assert "deflection phrase" in prompt.lower(), f"{driver}: no deflection section"
211
+
212
+
213
+ # ── transcribe_audio: missing API key ────────────────────────────────────────
214
+
215
+ class TestTranscribeAudioMissingKey:
216
+ def test_raises_environment_error_without_key(self):
217
+ from modal_backend.client import transcribe_audio
218
+
219
+ mock_cohere = MagicMock()
220
+ with patch.dict("sys.modules", {"cohere": mock_cohere}):
221
+ import os
222
+ saved = os.environ.pop("COHERE_API_KEY", None)
223
+ try:
224
+ with pytest.raises(EnvironmentError, match="COHERE_API_KEY"):
225
+ transcribe_audio(b"fake audio bytes")
226
+ finally:
227
+ if saved is not None:
228
+ os.environ["COHERE_API_KEY"] = saved
229
+
230
+
231
+ # ── call_persona_chat delegates correctly ─────────────────────────────────────
232
+
233
+ class TestCallPersonaChat:
234
+ def test_delegates_to_persona_chat(self):
235
+ mock_result = {"text": "I will win.", "audio_wav": b""}
236
+ with patch("modal_backend.client._persona_chat") as mock_fn:
237
+ mock_fn.remote.return_value = mock_result
238
+ from modal_backend.client import call_persona_chat
239
+
240
+ result = call_persona_chat(
241
+ system_prompt="You are Verstappen.",
242
+ user_message="Will you win?",
243
+ )
244
+ assert result["text"] == "I will win."
245
+ mock_fn.remote.assert_called_once()
246
+
247
+ def test_warmup_does_not_increment_call_count(self):
248
+ mock_result = {}
249
+ with patch("modal_backend.client._persona_chat") as mock_fn:
250
+ mock_fn.remote.return_value = mock_result
251
+ import modal_backend.client as client_mod
252
+
253
+ before = client_mod._persona_call_count
254
+ from modal_backend.client import call_persona_chat
255
+
256
+ call_persona_chat(system_prompt="", user_message="", warmup=True)
257
+ assert client_mod._persona_call_count == before
258
+
259
+ def test_non_warmup_increments_call_count(self):
260
+ mock_result = {"text": "ok", "audio_wav": b""}
261
+ with patch("modal_backend.client._persona_chat") as mock_fn:
262
+ mock_fn.remote.return_value = mock_result
263
+ import modal_backend.client as client_mod
264
+
265
+ before = client_mod._persona_call_count
266
+ from modal_backend.client import call_persona_chat
267
+
268
+ call_persona_chat(system_prompt="s", user_message="q", warmup=False)
269
+ assert client_mod._persona_call_count == before + 1
270
+
271
+
272
+ # ── get_persona_loading_message ───────────────────────────────────────────────
273
+
274
+ class TestGetPersonaLoadingMessage:
275
+ def test_returns_string(self):
276
+ from modal_backend.client import get_persona_loading_message
277
+
278
+ result = get_persona_loading_message()
279
+ assert isinstance(result, str)
280
+ assert len(result) > 5
281
+
tests/unit/test_prompt_builder.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Prompt Builder module (Issue #7)."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import pytest
8
+
9
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
10
+
11
+ from prompts.builder import (
12
+ build_commentary_prompt,
13
+ build_persona_prompt,
14
+ build_strategy_prompt,
15
+ _ANTI_HALLUCINATION,
16
+ _MINICPM_TOKEN_LIMIT,
17
+ _NEMOTRON_TOKEN_LIMIT,
18
+ _CHARS_PER_TOKEN,
19
+ )
20
+ from data.race_data import WINDOW_COLUMNS
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Fixture: minimal 10-lap × 2-driver DataFrame
25
+ # ---------------------------------------------------------------------------
26
+
27
+ @pytest.fixture()
28
+ def lap_df() -> pd.DataFrame:
29
+ rows = []
30
+ for lap in range(41, 51): # laps 41-50, pivot=45
31
+ for driver, pos, gap in [("VER", 1, 0.0), ("HAM", 2, 6.2)]:
32
+ rows.append({
33
+ "lap_number": lap,
34
+ "driver_code": driver,
35
+ "position": pos,
36
+ "gap_to_leader_s": gap,
37
+ "compound": "MEDIUM",
38
+ "tyre_life": lap - 30,
39
+ "lap_time_s": 90.1 + (lap * 0.01),
40
+ "sc_active": False,
41
+ })
42
+ return pd.DataFrame(rows, columns=WINDOW_COLUMNS)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Commentary — broadcast mode
47
+ # ---------------------------------------------------------------------------
48
+
49
+ class TestBroadcastMode:
50
+ def test_returns_string(self, lap_df):
51
+ result = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
52
+ assert isinstance(result, str)
53
+
54
+ def test_team_name_interpolated(self, lap_df):
55
+ result = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
56
+ assert "Oracle Red Bull Racing" in result
57
+
58
+ def test_lap_data_interpolated(self, lap_df):
59
+ result = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
60
+ assert "VER" in result
61
+ assert "HAM" in result
62
+
63
+ def test_broadcast_tone_marker_present(self, lap_df):
64
+ result = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
65
+ # Template contains "television broadcast" or "broadcast commentator"
66
+ assert "broadcast" in result.lower()
67
+
68
+ def test_within_minicpm_context_window(self, lap_df):
69
+ result = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
70
+ assert len(result) <= _MINICPM_TOKEN_LIMIT * _CHARS_PER_TOKEN
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Commentary — radio mode
75
+ # ---------------------------------------------------------------------------
76
+
77
+ class TestRadioMode:
78
+ def test_returns_string(self, lap_df):
79
+ result = build_commentary_prompt(lap_df, "Mercedes-AMG Petronas", "radio")
80
+ assert isinstance(result, str)
81
+
82
+ def test_team_name_interpolated(self, lap_df):
83
+ result = build_commentary_prompt(lap_df, "Mercedes-AMG Petronas", "radio")
84
+ assert "Mercedes-AMG Petronas" in result
85
+
86
+ def test_radio_tone_marker_present(self, lap_df):
87
+ result = build_commentary_prompt(lap_df, "Mercedes-AMG Petronas", "radio")
88
+ # Template contains "race engineer" or "team radio"
89
+ assert "radio" in result.lower() or "race engineer" in result.lower()
90
+
91
+ def test_within_minicpm_context_window(self, lap_df):
92
+ result = build_commentary_prompt(lap_df, "Mercedes-AMG Petronas", "radio")
93
+ assert len(result) <= _MINICPM_TOKEN_LIMIT * _CHARS_PER_TOKEN
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Broadcast vs radio are structurally different
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class TestBroadcastVsRadioStructure:
101
+ def test_prompts_are_different(self, lap_df):
102
+ broadcast = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
103
+ radio = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "radio")
104
+ assert broadcast != radio
105
+
106
+ def test_different_instruction_sections(self, lap_df):
107
+ broadcast = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "broadcast")
108
+ radio = build_commentary_prompt(lap_df, "Oracle Red Bull Racing", "radio")
109
+ # Broadcast instructs TV-style full names; radio uses terse fragments
110
+ assert "television" in broadcast.lower() or "global audience" in broadcast.lower()
111
+ assert "terse" in radio.lower() or "clipped" in radio.lower() or "race engineer" in radio.lower()
112
+
113
+ def test_invalid_mode_raises(self, lap_df):
114
+ with pytest.raises(ValueError, match="Unknown commentary mode"):
115
+ build_commentary_prompt(lap_df, "Red Bull", "freestyle")
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Strategy mode
120
+ # ---------------------------------------------------------------------------
121
+
122
+ class TestStrategyMode:
123
+ WHAT_IF = "What if Hamilton had pitted 5 laps earlier on lap 30 for fresh mediums?"
124
+
125
+ def test_returns_string(self, lap_df):
126
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
127
+ assert isinstance(result, str)
128
+
129
+ def test_lap_data_interpolated(self, lap_df):
130
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
131
+ assert "VER" in result
132
+ assert "HAM" in result
133
+
134
+ def test_what_if_variable_interpolated(self, lap_df):
135
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
136
+ assert self.WHAT_IF.strip() in result
137
+
138
+ def test_anti_hallucination_instruction_present(self, lap_df):
139
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
140
+ assert _ANTI_HALLUCINATION in result
141
+
142
+ def test_anti_hallucination_references_invented_values(self, lap_df):
143
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
144
+ assert "invent" in result.lower() or "fabricat" in result.lower()
145
+
146
+ def test_within_nemotron_context_window(self, lap_df):
147
+ result = build_strategy_prompt(lap_df, self.WHAT_IF)
148
+ assert len(result) <= _NEMOTRON_TOKEN_LIMIT * _CHARS_PER_TOKEN
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Persona mode — cutoff block
153
+ # ---------------------------------------------------------------------------
154
+
155
+ class TestPersonaMode:
156
+ def test_returns_string_for_active_driver(self):
157
+ result = build_persona_prompt("verstappen")
158
+ assert isinstance(result, str)
159
+ assert len(result) > 100
160
+
161
+ def test_cutoff_block_appended_for_active_driver(self):
162
+ for driver in ["verstappen", "hamilton", "norris"]:
163
+ result = build_persona_prompt(driver)
164
+ assert "Knowledge Cutoff" in result, f"{driver}: missing cutoff block"
165
+ assert "end of 2023" in result, f"{driver}: wrong cutoff year"
166
+
167
+ def test_cutoff_block_appended_for_historical_drivers(self):
168
+ result_senna = build_persona_prompt("senna")
169
+ assert "Knowledge Cutoff" in result_senna
170
+ assert "1994" in result_senna
171
+
172
+ result_schumi = build_persona_prompt("schumacher")
173
+ assert "Knowledge Cutoff" in result_schumi
174
+ assert "2012" in result_schumi
175
+
176
+ def test_race_context_injected_for_active_driver(self):
177
+ ctx = "2023 Dutch GP, Lap 42. Verstappen leads by 4.8s."
178
+ result = build_persona_prompt("verstappen", race_context=ctx)
179
+ assert ctx in result
180
+ assert "Current Race Context" in result
181
+
182
+ def test_race_context_absent_for_historical_driver(self):
183
+ ctx = "2023 Dutch GP, Lap 42."
184
+ result = build_persona_prompt("senna", race_context=ctx)
185
+ assert ctx not in result
186
+ assert "Current Race Context" not in result
187
+
188
+ result_s = build_persona_prompt("schumacher", race_context=ctx)
189
+ assert ctx not in result_s
190
+
191
+ def test_cutoff_block_comes_after_persona_text(self):
192
+ result = build_persona_prompt("verstappen")
193
+ persona_end = result.index("Knowledge Cutoff")
194
+ # There must be substantial persona content before the cutoff block
195
+ assert persona_end > 200
196
+
197
+ def test_missing_driver_raises_file_not_found(self):
198
+ with pytest.raises(FileNotFoundError):
199
+ build_persona_prompt("alonso")
200
+
201
+ def test_within_minicpm_context_window(self):
202
+ result = build_persona_prompt("verstappen")
203
+ assert len(result) <= _MINICPM_TOKEN_LIMIT * _CHARS_PER_TOKEN
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Prompt length validation
208
+ # ---------------------------------------------------------------------------
209
+
210
+ class TestPromptLengthValidation:
211
+ def test_oversized_strategy_prompt_raises(self):
212
+ # Build a DataFrame with enough rows to exceed Nemotron's limit
213
+ rows = []
214
+ for lap in range(1, 5001):
215
+ rows.append({
216
+ "lap_number": lap,
217
+ "driver_code": "VER",
218
+ "position": 1,
219
+ "gap_to_leader_s": 0.0,
220
+ "compound": "HARD",
221
+ "tyre_life": lap,
222
+ "lap_time_s": 90.0,
223
+ "sc_active": False,
224
+ })
225
+ huge_df = pd.DataFrame(rows, columns=WINDOW_COLUMNS)
226
+ with pytest.raises(ValueError, match="context window"):
227
+ build_strategy_prompt(huge_df, "any change")
228
+
229
+ def test_max_10lap_2driver_strategy_within_bounds(self, lap_df):
230
+ # The standard 10-lap × 2-driver window must not trip the limit
231
+ result = build_strategy_prompt(lap_df, "Hamilton pits 5 laps earlier.")
232
+ assert len(result) <= _NEMOTRON_TOKEN_LIMIT * _CHARS_PER_TOKEN
tests/unit/test_race_data.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Race Data Module (Issue #3)."""
2
+
3
+ import io
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import pytest
8
+
9
+ import sys
10
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
11
+
12
+ import data.race_data as rdm
13
+
14
+ FIXTURES = Path(__file__).parent.parent / "fixtures"
15
+
16
+ # Minimal YAML covering one race that matches the fixture filename
17
+ _YAML_CONTENT = b"""
18
+ races:
19
+ - season: 2023
20
+ round: 10
21
+ name: "British GP"
22
+ circuit: "Silverstone"
23
+ modes: [what_if, demo_anchor]
24
+ """
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def patch_paths(monkeypatch, tmp_path):
29
+ """Redirect module to use fixture Parquet and inline YAML."""
30
+ yaml_file = tmp_path / "curated_races.yaml"
31
+ yaml_file.write_bytes(_YAML_CONTENT)
32
+ monkeypatch.setattr(rdm, "_CACHE_DIR", FIXTURES)
33
+ monkeypatch.setattr(rdm, "_YAML_PATH", yaml_file)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Window correctness
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class TestWindowBounds:
41
+ def test_mid_race_pivot_returns_11_laps(self):
42
+ df = rdm.get_lap_window(2023, 10, 30, "VER", "HAM")
43
+ laps = sorted(df["lap_number"].unique())
44
+ assert laps == list(range(25, 36))
45
+
46
+ def test_mid_race_only_two_drivers(self):
47
+ df = rdm.get_lap_window(2023, 10, 30, "VER", "HAM")
48
+ assert set(df["driver_code"].unique()) == {"VER", "HAM"}
49
+
50
+ def test_pivot_near_start_truncates_at_lap_1(self):
51
+ df = rdm.get_lap_window(2023, 10, 3, "VER", "HAM")
52
+ assert df["lap_number"].min() == 1
53
+ assert df["lap_number"].max() == 8
54
+
55
+ def test_pivot_near_end_truncates_at_final_lap(self):
56
+ # Fixture has 52 laps; pivot 50 => window [45, 52]
57
+ df = rdm.get_lap_window(2023, 10, 50, "VER", "HAM")
58
+ assert df["lap_number"].min() == 45
59
+ assert df["lap_number"].max() == 52
60
+
61
+ def test_result_sorted_by_lap_then_driver(self):
62
+ df = rdm.get_lap_window(2023, 10, 20, "VER", "HAM")
63
+ expected = df.sort_values(["lap_number", "driver_code"]).reset_index(drop=True)
64
+ pd.testing.assert_frame_equal(df.reset_index(drop=True), expected)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Columns
69
+ # ---------------------------------------------------------------------------
70
+
71
+ class TestColumns:
72
+ def test_returns_exactly_required_columns(self):
73
+ df = rdm.get_lap_window(2023, 10, 20, "VER", "HAM")
74
+ assert list(df.columns) == rdm.WINDOW_COLUMNS
75
+
76
+ def test_sc_active_is_boolean(self):
77
+ df = rdm.get_lap_window(2023, 10, 10, "VER", "HAM")
78
+ assert df["sc_active"].dtype == bool
79
+
80
+ def test_sc_active_true_on_sc_laps(self):
81
+ df = rdm.get_lap_window(2023, 10, 10, "VER", "HAM")
82
+ sc_laps = set(df.loc[df["sc_active"], "lap_number"].unique())
83
+ # Fixture sets sc_active on laps 10, 11, 12
84
+ assert {10, 11, 12}.issubset(sc_laps)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Error cases
89
+ # ---------------------------------------------------------------------------
90
+
91
+ class TestErrors:
92
+ def test_wrong_driver_code_raises_value_error(self):
93
+ with pytest.raises(ValueError) as exc_info:
94
+ rdm.get_lap_window(2023, 10, 20, "VER", "ZZZ")
95
+ assert "ZZZ" in str(exc_info.value)
96
+ assert "Available drivers" in str(exc_info.value)
97
+
98
+ def test_missing_parquet_raises_file_not_found(self, monkeypatch, tmp_path):
99
+ monkeypatch.setattr(rdm, "_CACHE_DIR", tmp_path)
100
+ with pytest.raises(FileNotFoundError) as exc_info:
101
+ rdm.get_lap_window(2023, 10, 20, "VER", "HAM")
102
+ assert "fetch_races.py" in str(exc_info.value)
103
+ assert "2023" in str(exc_info.value)
104
+
105
+ def test_corrupted_parquet_raises_value_error(self, monkeypatch, tmp_path):
106
+ bad_file = tmp_path / "2023_British_GP_laps.parquet"
107
+ bad_file.write_bytes(b"this is not a parquet file")
108
+ monkeypatch.setattr(rdm, "_CACHE_DIR", tmp_path)
109
+ with pytest.raises(ValueError) as exc_info:
110
+ rdm.get_lap_window(2023, 10, 20, "VER", "HAM")
111
+ assert "Failed to read" in str(exc_info.value)
112
+
113
+ def test_unknown_race_raises_value_error(self):
114
+ with pytest.raises(ValueError) as exc_info:
115
+ rdm.get_lap_window(2021, 1, 20, "VER", "HAM")
116
+ assert "not found in curated_races.yaml" in str(exc_info.value)