diff --git a/CLIENT_FIELD_CROSSWALK.md b/CLIENT_FIELD_CROSSWALK.md new file mode 100644 index 0000000..191d0ab --- /dev/null +++ b/CLIENT_FIELD_CROSSWALK.md @@ -0,0 +1,78 @@ +# Client Field Crosswalk (Basic) + +This document maps the client’s requested scoreboard display values against the current `GET /live` response. + +Scope is intentionally basic and focused on what is needed for a first pass display. + +## 1) Requested fields vs `GET /live` + +| Client requested value | Available in `GET /live` | Field(s) | +|---|---|---| +| Period | Yes | `period` | +| Jam | Yes | `jam` | +| Period Clock | Yes | `period_clock_ms` | +| Jam Clock | Yes | `jam_clock_ms` | +| Home Score | Yes | `team1.score` | +| Away Score | Yes | `team2.score` | +| Home Jam Score | Yes | `team1.jam_score` | +| Away Jam Score | Yes | `team2.jam_score` | +| Home PP Timer | Yes (via skater box timer) | `team1.jammer.in_box`, `team1.jammer.box_time_remaining_s` | +| Away PP Timer | Yes (via skater box timer) | `team2.jammer.in_box`, `team2.jammer.box_time_remaining_s` | +| Home Player Tracker (1-5) | Yes | `team1.jammer`, `team1.pivot`, `team1.blocker1`, `team1.blocker2`, `team1.blocker3` | +| Away Player Tracker (1-5) | Yes | `team2.jammer`, `team2.pivot`, `team2.blocker1`, `team2.blocker2`, `team2.blocker3` | + +Notes: +- Player tracker rows include skater `name`, `number`, and penalty-box state (`in_box`, `box_time_remaining_s`). +- PP timer can be rendered from jammer box state/timer for each team. + +## 2) Key values currently missing from `GET /live` + +The following are not currently mapped into `GET /live` and should be added for the production broadcast display: + +1. Lead jammer skater (explicit display field) + - Today, lead can be inferred via `team1.lead` / `team2.lead` plus jammer identity, but there is no explicit top-level “lead jammer skater” value. + +2. Timeout state and type + - Team timeout + - Official timeout + - Official review + +3. Timeout ownership and counters + - Which team called timeout/review + - Team timeouts remaining per team + - Official review availability/status per team + +4. Timeout/review timer behavior + - Team timeout clock (fixed 60s) + - Official timeout timing (variable duration) + - Official review timing (variable duration) + +5. Post-timeout to jam-start phase + - A display flag/timer for the window between timeout end and next jam start. + +## 3) Rules/operations notes to confirm with league + +- Team timeouts: each team has three total, but event policy should confirm whether tracked per game or displayed per half in this production. +- Official review: one per team per half, and may be retained if successful (once). +- Official timeout and official review durations are not fixed. + +These rules are officiating policy context. The API should still expose neutral fields (type, owner, running, remaining, total/used/remaining) so the display can apply event-specific presentation. + +## 4) Minimal overview fields for immediate implementation + +For the fastest basic display, include this overview set: + +- State: `period`, `jam`, `game_state`, `in_jam`, `jam_running` +- Clocks: `period_clock_ms`, `jam_clock_ms` +- Scores: `team1.score`, `team2.score`, `team1.jam_score`, `team2.jam_score` +- Lead and star pass indicators: `team1.lead`, `team2.lead`, `team1.star_pass`, `team2.star_pass` +- Player tracker: all five positions for both teams (name, number, in-box, box timer) +- Timeout package (to add): timeout type, owner, running flag, remaining ms (when fixed), team timeout counts, official review status, post-timeout phase indicator + +## 5) Follow-up implementation plan + +1. Use `GET /raw` during a live timeout/review sequence to capture exact CRG key names. +2. Add timeout/review fields to `models.py` (`LiveState` and/or `TeamState`). +3. Map those keys in `client.py` field maps. +4. Add tests for team timeout, official timeout, official review, and post-timeout phase. +5. Update display UI to render timeout/review banner and counters. diff --git a/README.md b/README.md index 9b57f7e..f15832e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Clean, mapped live game state. Poll this at whatever rate suits your overlay (20 "jam_running": true, "in_jam": true, "game_state": "Running", + "timeout_type": null, "state_age_seconds": 0.1, "team1": { "name": "Home Team", @@ -109,6 +110,10 @@ Clean, mapped live game state. Poll this at whatever rate suits your overlay (20 > `null` means no update has been received yet (proxy just connected). If this grows above a few > seconds while `connected` is `true`, the scoreboard may be frozen. +> **`timeout_type`:** Normalized timeout/review state derived from `game_state`. +> Values: `team_timeout`, `official_timeout`, `official_review`, `timeout`, or `null`. +> It is forced to `null` when `jam_running` is `true` (play resumed). + ### `GET /raw` Full flat state dict as received from the scoreboard WebSocket. Useful for discovering all available fields or debugging. @@ -136,6 +141,10 @@ The proxy is designed to **stay running no matter what**: See [EXTENDING.md](EXTENDING.md) for a guide on adding new fields, endpoints, and more. +## Client display field crosswalk + +For a client-facing summary of requested scoreboard fields, current `/live` coverage, and missing timeout/review data needed for broadcast UI, see [CLIENT_FIELD_CROSSWALK.md](CLIENT_FIELD_CROSSWALK.md). + ## Running tests ```powershell diff --git a/client.py b/client.py index 672e5f5..a47a4f6 100644 --- a/client.py +++ b/client.py @@ -207,14 +207,40 @@ def _team(self, n: int) -> TeamState: ) return TeamState(**flat_fields, **positions) + @staticmethod + def _normalize_timeout_type(game_state: Optional[str], jam_running: Optional[bool]) -> Optional[str]: + """Normalize timeout/review from game state; clear once jam is running again.""" + if jam_running: + return None + if not game_state: + return None + + state = game_state.strip().lower() + if "official review" in state: + return "official_review" + if "official timeout" in state: + return "official_timeout" + if "team timeout" in state: + return "team_timeout" + if "timeout" in state: + return "timeout" + if "review" in state: + return "official_review" + return None + def get_live_state(self) -> LiveState: """Build and return a LiveState model from current raw state.""" game_fields = { field: self._get(suffix, typ) for field, (suffix, typ) in GAME_FIELD_MAP.items() } + timeout_type = self._normalize_timeout_type( + game_state=game_fields.get("game_state"), + jam_running=game_fields.get("jam_running"), + ) return LiveState( **game_fields, + timeout_type=timeout_type, team1=self._team(1), team2=self._team(2), ) diff --git a/models.py b/models.py index 76cc217..0dfcd41 100644 --- a/models.py +++ b/models.py @@ -49,6 +49,14 @@ class LiveState(BaseModel): jam_running: Optional[bool] = None in_jam: Optional[bool] = None game_state: Optional[str] = None + timeout_type: Optional[str] = Field( + default=None, + description=( + "Normalized timeout/review state. " + "One of: team_timeout, official_timeout, official_review, timeout, or null. " + "Resets to null when jam_running is true." + ), + ) state_age_seconds: Optional[float] = None team1: TeamState = Field(default_factory=TeamState) team2: TeamState = Field(default_factory=TeamState) diff --git a/tests/test_api.py b/tests/test_api.py index 1e8369b..a59468d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -87,6 +87,12 @@ async def test_live_jammer_names(app_client): assert data["team2"]["jammer"]["name"] == "Lightning Bolt" +async def test_live_includes_timeout_type_field(app_client): + resp = await app_client.get("/live") + data = resp.json() + assert "timeout_type" in data + + async def test_live_reflects_score_update(app_client, mock_server): await mock_server.push_update({ "ScoreBoard.CurrentGame.Team(2).Score": 50, diff --git a/tests/test_client.py b/tests/test_client.py index 331dced..394dbbc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,9 +16,6 @@ from tests.conftest import MockScoreboardServer, INITIAL_STATE -pytestmark = pytest.mark.asyncio - - async def _connected_client(server: MockScoreboardServer) -> ScoreboardClient: """Start a client, wait for it to receive the initial snapshot.""" client = ScoreboardClient(host="127.0.0.1", port=server.port) @@ -31,6 +28,7 @@ async def _connected_client(server: MockScoreboardServer) -> ScoreboardClient: return client, task +@pytest.mark.asyncio async def test_client_connects_and_receives_initial_state(mock_server): client, task = await _connected_client(mock_server) try: @@ -41,6 +39,7 @@ async def test_client_connects_and_receives_initial_state(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_get_live_state_maps_team_names(mock_server): client, task = await _connected_client(mock_server) try: @@ -52,6 +51,7 @@ async def test_get_live_state_maps_team_names(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_get_live_state_maps_scores(mock_server): client, task = await _connected_client(mock_server) try: @@ -63,6 +63,7 @@ async def test_get_live_state_maps_scores(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_get_live_state_maps_clocks(mock_server): client, task = await _connected_client(mock_server) try: @@ -77,6 +78,7 @@ async def test_get_live_state_maps_clocks(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_get_live_state_maps_jammer_info(mock_server): client, task = await _connected_client(mock_server) try: @@ -90,6 +92,7 @@ async def test_get_live_state_maps_jammer_info(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_get_live_state_maps_all_positions(mock_server): client, task = await _connected_client(mock_server) try: @@ -110,6 +113,7 @@ async def test_get_live_state_maps_all_positions(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_penalty_box_defaults_false_when_key_absent(mock_server): """When CRG has never sent a PenaltyBox key, in_box must default to False (not None).""" client, task = await _connected_client(mock_server) @@ -130,6 +134,7 @@ async def test_penalty_box_defaults_false_when_key_absent(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_penalty_box_update(mock_server): client, task = await _connected_client(mock_server) try: @@ -145,6 +150,7 @@ async def test_penalty_box_update(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_entered_at_ms_is_none_when_not_in_box(mock_server): """box_entered_at_ms is None for skaters not in the penalty box.""" client, task = await _connected_client(mock_server) @@ -156,6 +162,7 @@ async def test_box_entered_at_ms_is_none_when_not_in_box(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_entered_at_ms_set_on_entry(mock_server): """box_entered_at_ms is set to a recent epoch-ms timestamp on box entry.""" client, task = await _connected_client(mock_server) @@ -177,6 +184,7 @@ async def test_box_entered_at_ms_set_on_entry(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_entered_at_ms_cleared_on_exit(mock_server): """box_entered_at_ms returns to None when in_box transitions to False.""" client, task = await _connected_client(mock_server) @@ -199,6 +207,7 @@ async def test_box_entered_at_ms_cleared_on_exit(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_time_remaining_s_none_when_not_in_box(mock_server): """box_time_remaining_s is None for skaters not in the penalty box.""" client, task = await _connected_client(mock_server) @@ -210,6 +219,7 @@ async def test_box_time_remaining_s_none_when_not_in_box(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_time_remaining_s_counts_down(mock_server): """box_time_remaining_s reflects correct remaining seconds after box entry.""" client, task = await _connected_client(mock_server) @@ -228,6 +238,7 @@ async def test_box_time_remaining_s_counts_down(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_time_remaining_s_zero_when_expired(mock_server): """box_time_remaining_s is 0 when the 30-second window has already passed.""" from client import PENALTY_BOX_DURATION_S @@ -249,6 +260,7 @@ async def test_box_time_remaining_s_zero_when_expired(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_box_entered_at_ms_not_reset_on_repeated_true(mock_server): """box_entered_at_ms is not updated if in_box is already True (no re-entry).""" client, task = await _connected_client(mock_server) @@ -272,6 +284,7 @@ async def test_box_entered_at_ms_not_reset_on_repeated_true(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_state_update_reflects_new_score(mock_server): client, task = await _connected_client(mock_server) try: @@ -288,6 +301,7 @@ async def test_state_update_reflects_new_score(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_null_value_deletes_key(mock_server): client, task = await _connected_client(mock_server) try: @@ -303,6 +317,7 @@ async def test_null_value_deletes_key(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_lead_and_display_lead(mock_server): client, task = await _connected_client(mock_server) try: @@ -320,6 +335,52 @@ async def test_lead_and_display_lead(mock_server): await asyncio.sleep(0.05) +@pytest.mark.parametrize( + "game_state, expected", + [ + ("Team Timeout", "team_timeout"), + ("Official Timeout", "official_timeout"), + ("Official Review", "official_review"), + ], +) +@pytest.mark.asyncio +async def test_timeout_type_from_game_state(mock_server, game_state, expected): + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": False, + "ScoreBoard.CurrentGame.State": game_state, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.timeout_type == expected + finally: + client.stop() + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_timeout_type_clears_when_jam_running(mock_server): + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": False, + "ScoreBoard.CurrentGame.State": "Official Timeout", + }) + await asyncio.sleep(0.1) + assert client.get_live_state().timeout_type == "official_timeout" + + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + }) + await asyncio.sleep(0.1) + assert client.get_live_state().timeout_type is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio async def test_raw_state_returns_full_dict(mock_server): client, task = await _connected_client(mock_server) try: @@ -331,6 +392,7 @@ async def test_raw_state_returns_full_dict(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_reconnects_after_server_restart(mock_server): client, task = await _connected_client(mock_server) try: @@ -380,6 +442,7 @@ def test_coerce_none_returns_none_for_bool(): # Bug: _apply_update with non-dict state (e.g. null) must not kill the WS loop +@pytest.mark.asyncio async def test_null_state_patch_does_not_kill_connection(mock_server): """Sending {"state": null} should not crash the receive loop.""" client, task = await _connected_client(mock_server) @@ -398,6 +461,7 @@ async def test_null_state_patch_does_not_kill_connection(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_list_state_patch_does_not_kill_connection(mock_server): """Sending {"state": [...]} should not crash the receive loop.""" client, task = await _connected_client(mock_server) @@ -412,6 +476,7 @@ async def test_list_state_patch_does_not_kill_connection(mock_server): await asyncio.sleep(0.05) +@pytest.mark.asyncio async def test_seconds_since_update_is_populated_after_connect(mock_server): """After receiving the initial state snapshot, seconds_since_update must be a float >= 0.""" client, task = await _connected_client(mock_server) @@ -423,3 +488,89 @@ async def test_seconds_since_update_is_populated_after_connect(mock_server): finally: client.stop() await asyncio.sleep(0.05) + + +# Additional coverage tests +# --------------------------------------------------------------------------- + +def test_coerce_invalid_type_returns_original_value(): + """Test _coerce with invalid type conversion that raises ValueError or TypeError.""" + # Try to convert "invalid" to int - should return original value + assert _coerce("invalid", int) == "invalid" + + # Try to convert None to a complex type that would raise TypeError + class TestClass: + def __init__(self, value): + if value is None: + raise TypeError("Cannot create TestClass from None") + self.value = value + + assert _coerce(None, TestClass) is None + + +def test_normalize_timeout_type_additional_cases(): + """Test additional timeout type case variants.""" + from client import ScoreboardClient + + # Test generic "timeout" case + assert ScoreboardClient._normalize_timeout_type("Timeout", False) == "timeout" + + # Test "review" case + assert ScoreboardClient._normalize_timeout_type("Review", False) == "official_review" + + # Test unrecognized state + assert ScoreboardClient._normalize_timeout_type("Running", False) is None + + # Test null/empty cases + assert ScoreboardClient._normalize_timeout_type("", False) is None + + +@pytest.mark.asyncio +async def test_client_handles_json_decode_error(mock_server): + """Test that client handles non-JSON messages gracefully.""" + client, task = await _connected_client(mock_server) + try: + # Send an invalid JSON message through the mock server + await mock_server.push_raw("invalid json {") + await asyncio.sleep(0.1) + + # Client should still be connected and functioning + assert client.connected is True + state = client.get_live_state() + assert state.team1.score == 42 # Original state should be intact + finally: + client.stop() + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_client_handles_message_processing_error(mock_server): + """Test that client handles errors in message processing gracefully.""" + client, task = await _connected_client(mock_server) + try: + # Send a message with state that would cause processing issues + await mock_server.push_update({"invalid": "structure that might cause errors"}) + await asyncio.sleep(0.1) + + # Client should still be connected + assert client.connected is True + finally: + client.stop() + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +async def test_client_handles_error_message_from_scoreboard(mock_server): + """Test client handles error messages from scoreboard.""" + import json + client, task = await _connected_client(mock_server) + try: + # Send an error message as the scoreboard might + await mock_server.push_raw(json.dumps({"error": "Test error from scoreboard"})) + await asyncio.sleep(0.1) + + # Client should still be connected despite error + assert client.connected is True + finally: + client.stop() + await asyncio.sleep(0.05) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..f0c944c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,57 @@ +import pytest +import asyncio +from unittest.mock import Mock, patch, AsyncMock +from fastapi.testclient import TestClient + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import main +from client import ScoreboardClient + + +def test_create_app(): + """Test that create_app creates a FastAPI application with correct configuration.""" + app = main.create_app(scoreboard_host="testhost", scoreboard_port=9999) + + # Check that app has a scoreboard client configured + assert hasattr(app.state, "scoreboard_client") + assert isinstance(app.state.scoreboard_client, ScoreboardClient) + assert app.state.scoreboard_client.host == "testhost" + assert app.state.scoreboard_client.port == 9999 + + +def test_create_app_defaults(): + """Test create_app with default parameters.""" + app = main.create_app() + + assert app.state.scoreboard_client.host == "localhost" + assert app.state.scoreboard_client.port == 8000 + + +def test_parse_args(): + """Test argument parsing function.""" + with patch('sys.argv', ['main.py', '--scoreboard-host', 'custom-host', '--port', '3000']): + args = main.parse_args() + assert args.scoreboard_host == 'custom-host' + assert args.port == 3000 + assert args.scoreboard_port == 8000 # default + assert args.host == '0.0.0.0' # default + + +def test_parse_args_all_options(): + """Test argument parsing with all options.""" + test_argv = [ + 'main.py', + '--scoreboard-host', 'sb-host', + '--scoreboard-port', '9000', + '--host', '127.0.0.1', + '--port', '5555' + ] + with patch('sys.argv', test_argv): + args = main.parse_args() + assert args.scoreboard_host == 'sb-host' + assert args.scoreboard_port == 9000 + assert args.host == '127.0.0.1' + assert args.port == 5555 \ No newline at end of file