F2 Control screen Manual mode now has a row of step-size buttons (0.1°, 0.5°, 1°, 5°, 10°) that scale arrow key nudges, plus AZ/EL velocity inputs with Apply button for runtime motor speed tuning via firmware mv/ma commands. Bridge: set_max_velocity(), set_max_acceleration() write methods. Demo: mutable dynamics + console mv/ma write handling. Tests: 8 Textual pilot tests covering button toggle, nudge math, velocity population, and apply round-trip.
284 lines
9.0 KiB
Python
284 lines
9.0 KiB
Python
"""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")
|