"""Test step size selector and motor velocity controls on the F2 Control screen. Uses Textual's run_test() and pilot to drive the TUI in demo mode. Verifies step size button toggling, nudge multiplication, velocity input population, and velocity apply round-trip through DemoDevice. NOTE: pilot.click() on dock:bottom widgets can miss due to headless coordinate mapping. We use post_message(Button.Pressed(...)) for reliable button activation in CI, and pilot.press() for keyboard interaction. """ import asyncio import pytest from textual.widgets import Button, Input from birdcage_tui.app import BirdcageApp from birdcage_tui.screens.control import ControlScreen def _press_button(btn: Button) -> None: """Programmatically press a Button by posting its Pressed message. Bypasses pilot.click() coordinate issues with docked widgets. """ btn.post_message(Button.Pressed(btn)) @pytest.mark.asyncio async def test_step_size_default_is_one(): """Default step size should be 1.0 degree.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() control = app.query_one("#control", ControlScreen) assert control._step_size == 1.0 # The 1° button should have the "selected" class. btn_1 = app.query_one("#btn-step-1_0", Button) assert "selected" in btn_1.classes @pytest.mark.asyncio async def test_step_size_button_switches(): """Pressing a step-size button should update _step_size and highlight.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() control = app.query_one("#control", ControlScreen) # Press the 0.1° button. btn_01 = app.query_one("#btn-step-0_1", Button) _press_button(btn_01) await pilot.pause() assert control._step_size == 0.1 assert "selected" in btn_01.classes # The old 1° button should no longer be selected. btn_1 = app.query_one("#btn-step-1_0", Button) assert "selected" not in btn_1.classes # Press the 10° button. btn_10 = app.query_one("#btn-step-10_0", Button) _press_button(btn_10) await pilot.pause() assert control._step_size == 10.0 assert "selected" in btn_10.classes assert "selected" not in btn_01.classes @pytest.mark.asyncio async def test_step_size_cycles_all(): """Pressing each step button in sequence should set the correct value.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() control = app.query_one("#control", ControlScreen) expected = [ ("btn-step-0_1", 0.1), ("btn-step-0_5", 0.5), ("btn-step-1_0", 1.0), ("btn-step-5_0", 5.0), ("btn-step-10_0", 10.0), ] for btn_id, expected_size in expected: btn = app.query_one(f"#{btn_id}", Button) _press_button(btn) await pilot.pause() assert control._step_size == expected_size, ( f"After pressing #{btn_id}, step_size should be " f"{expected_size}, got {control._step_size}" ) assert "selected" in btn.classes # All other buttons should NOT be selected. for other_id, _ in expected: if other_id != btn_id: other = app.query_one(f"#{other_id}", Button) assert "selected" not in other.classes, ( f"#{other_id} should not be selected when #{btn_id} is active" ) @pytest.mark.asyncio async def test_nudge_uses_step_size(): """Arrow key nudge should multiply delta by the selected step size.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() control = app.query_one("#control", ControlScreen) # Wait for device to be set and position poll to run. await asyncio.sleep(1.0) # Record starting position. start_az = control._last_az # Set step size to 5 degrees. btn_5 = app.query_one("#btn-step-5_0", Button) _press_button(btn_5) await pilot.pause() assert control._step_size == 5.0 # Focus the control screen for key bindings. control.focus() await pilot.pause() # Press right arrow to nudge AZ +1 * 5.0 = +5.0. await pilot.press("right") await asyncio.sleep(0.5) # The demo device target should have moved by ~5 degrees. target_az = app.device._target_az expected = start_az + 5.0 assert abs(target_az - expected) < 0.1, ( f"Expected target AZ ~{expected}, got {target_az}" ) @pytest.mark.asyncio async def test_nudge_fine_step(): """0.1 deg step size should produce sub-degree nudges.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() control = app.query_one("#control", ControlScreen) await asyncio.sleep(1.0) start_el = control._last_el # Select 0.1° step size. btn_01 = app.query_one("#btn-step-0_1", Button) _press_button(btn_01) await pilot.pause() control.focus() await pilot.pause() # Press up arrow to nudge EL +1 * 0.1 = +0.1. await pilot.press("up") await asyncio.sleep(0.5) target_el = app.device._target_el expected = start_el + 0.1 assert abs(target_el - expected) < 0.05, ( f"Expected target EL ~{expected:.1f}, got {target_el:.2f}" ) @pytest.mark.asyncio async def test_velocity_inputs_populated(): """Velocity inputs should auto-populate from device motor dynamics.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() # Wait for the _load_motor_dynamics worker to complete. await asyncio.sleep(1.0) az_vel_input = app.query_one("#ctrl-az-vel", Input) el_vel_input = app.query_one("#ctrl-el-vel", Input) assert az_vel_input.value == "65.0", ( f"AZ velocity should be '65.0', got {az_vel_input.value!r}" ) assert el_vel_input.value == "45.0", ( f"EL velocity should be '45.0', got {el_vel_input.value!r}" ) @pytest.mark.asyncio async def test_velocity_apply(): """Clicking Apply should update the demo device motor velocities.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() await asyncio.sleep(1.0) # Set new velocity values directly on the Input widgets. az_vel_input = app.query_one("#ctrl-az-vel", Input) el_vel_input = app.query_one("#ctrl-el-vel", Input) az_vel_input.value = "55.0" el_vel_input.value = "30.0" await pilot.pause() # Press Apply via message posting. apply_btn = app.query_one("#btn-ctrl-apply-vel", Button) _press_button(apply_btn) # Wait for the worker thread to run the commands. await asyncio.sleep(1.0) # Verify the demo device stored the new velocities. dynamics = app.device.get_motor_dynamics() assert dynamics["az_max_vel"] == 55.0, ( f"AZ velocity should be 55.0, got {dynamics['az_max_vel']}" ) assert dynamics["el_max_vel"] == 30.0, ( f"EL velocity should be 30.0, got {dynamics['el_max_vel']}" ) @pytest.mark.asyncio async def test_all_step_buttons_exist(): """All five step size buttons should render in Manual mode.""" app = BirdcageApp() app.demo_mode = True async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.press("f2") await pilot.pause() expected_ids = [ "btn-step-0_1", "btn-step-0_5", "btn-step-1_0", "btn-step-5_0", "btn-step-10_0", ] for btn_id in expected_ids: btn = app.query_one(f"#{btn_id}", Button) assert btn is not None, f"Button #{btn_id} should exist" app.save_screenshot("/tmp/birdcage_step_size_test.svg")