Commit ·
97e14f1
1
Parent(s): ccecc26
sync all project files
Browse files- app.py +1 -2
- tests/fixtures/2023_British_GP_laps.parquet +3 -0
- tests/fixtures/make_fixtures.py +48 -0
- tests/smoke/test_modal_client.py +49 -0
- tests/test_what_if_tab.py +148 -0
- tests/unit/test_commentary_tab.py +167 -0
- tests/unit/test_persona.py +186 -0
- tests/unit/test_persona_chat.py +281 -0
- tests/unit/test_prompt_builder.py +232 -0
- tests/unit/test_race_data.py +116 -0
app.py
CHANGED
|
@@ -201,12 +201,11 @@ F1_CSS = """
|
|
| 201 |
|
| 202 |
#race-topbar {
|
| 203 |
margin-top: 14px;
|
| 204 |
-
background: rgba(23, 23, 23, 0.
|
| 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)
|