Files
StarPilot/selfdrive/ui/tests/test_aethergrid.py
2026-05-31 13:53:06 -04:00

402 lines
14 KiB
Python

import importlib
import sys
import types
import unittest
from unittest.mock import MagicMock
MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid"
PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.panel"
SECTIONED_PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel"
def _clear_modules(*module_names):
for module_name in module_names:
sys.modules.pop(module_name, None)
def _register_modules(module_map):
for name, module in module_map.items():
sys.modules[name] = module
def _make_texture(width=80, height=80):
return types.SimpleNamespace(width=width, height=height)
def _install_aethergrid_stubs():
rl = types.SimpleNamespace(
Color=lambda r, g, b, a=255: types.SimpleNamespace(r=r, g=g, b=b, a=a),
Rectangle=lambda x=0, y=0, width=0, height=0: types.SimpleNamespace(x=x, y=y, width=width, height=height),
Vector2=lambda x=0, y=0: types.SimpleNamespace(x=x, y=y),
Texture2D=type("Texture2D", (), {}),
Font=type("Font", (), {}),
GuiTextAlignment=types.SimpleNamespace(TEXT_ALIGN_CENTER=0),
WHITE=types.SimpleNamespace(r=255, g=255, b=255, a=255),
draw_rectangle_rounded=lambda *a, **k: None,
draw_rectangle_rounded_lines_ex=lambda *a, **k: None,
draw_rectangle_rec=lambda *a, **k: None,
draw_rectangle=lambda *a, **k: None,
draw_rectangle_gradient_v=lambda *a, **k: None,
draw_ring=lambda *a, **k: None,
draw_circle=lambda *a, **k: None,
draw_line=lambda *a, **k: None,
draw_line_ex=lambda *a, **k: None,
draw_triangle=lambda *a, **k: None,
draw_texture_pro=lambda *a, **k: None,
draw_text_ex=lambda *a, **k: None,
check_collision_point_rec=lambda *a, **k: False,
get_frame_time=lambda: 0.016,
get_mouse_position=lambda: types.SimpleNamespace(x=0, y=0),
)
sys.modules["pyray"] = rl
app_mod = types.ModuleType("openpilot.system.ui.lib.application")
app_mod.FontWeight = types.SimpleNamespace(BOLD=700, NORMAL=400, MEDIUM=500, SEMI_BOLD=600)
app_mod.MousePos = type("MousePos", (), {})
app_mod.MouseEvent = type("MouseEvent", (), {})
app_mod.gui_app = types.SimpleNamespace(
width=1920,
height=1080,
last_mouse_event=types.SimpleNamespace(pos=types.SimpleNamespace(x=0, y=0)),
font=lambda *_a, **_k: object(),
texture=lambda *_a, **_k: _make_texture(),
pop_widget=lambda: None,
)
sys.modules["openpilot.system.ui.lib.application"] = app_mod
scroll_panel_mod = types.ModuleType("openpilot.system.ui.lib.scroll_panel2")
class GuiScrollPanel2:
def __init__(self, horizontal=True, handle_out_of_bounds=True):
self._enabled = True
self._offset = 0.0
def set_enabled(self, enabled):
self._enabled = enabled
def update(self, bounds, content_size):
return 0.0
def is_touch_valid(self):
return True
scroll_panel_mod.GuiScrollPanel2 = GuiScrollPanel2
sys.modules["openpilot.system.ui.lib.scroll_panel2"] = scroll_panel_mod
multilang_mod = types.ModuleType("openpilot.system.ui.lib.multilang")
multilang_mod.tr = lambda text: text
sys.modules["openpilot.system.ui.lib.multilang"] = multilang_mod
text_measure_mod = types.ModuleType("openpilot.system.ui.lib.text_measure")
text_measure_mod.measure_text_cached = lambda *_a, **_k: types.SimpleNamespace(x=100, y=20)
sys.modules["openpilot.system.ui.lib.text_measure"] = text_measure_mod
widgets_mod = types.ModuleType("openpilot.system.ui.widgets")
class Widget:
def __init__(self):
self._rect = rl.Rectangle()
self._parent_rect = None
self._enabled = True
self.is_pressed = False
@property
def enabled(self):
return self._enabled() if callable(self._enabled) else self._enabled
def set_rect(self, rect):
self._rect = rect
def set_parent_rect(self, rect):
self._parent_rect = rect
def render(self, rect):
self._rect = rect
return self._render(rect)
def set_click_callback(self, callback):
self.on_click = callback
def set_enabled(self, enabled):
self._enabled = enabled
def _render(self, rect):
return None
widgets_mod.Widget = Widget
widgets_mod.DialogResult = types.SimpleNamespace(CONFIRM=1, CANCEL=0, NO_ACTION=-1)
sys.modules["openpilot.system.ui.widgets"] = widgets_mod
option_dialog_mod = types.ModuleType("openpilot.system.ui.widgets.option_dialog")
option_dialog_mod.MultiOptionDialog = type("MultiOptionDialog", (), {})
sys.modules["openpilot.system.ui.widgets.option_dialog"] = option_dialog_mod
label_mod = types.ModuleType("openpilot.system.ui.widgets.label")
label_mod.gui_label = lambda *a, **k: None
sys.modules["openpilot.system.ui.widgets.label"] = label_mod
asset_loader_mod = types.ModuleType("openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader")
asset_loader_mod.starpilot_texture = lambda *_a, **_k: _make_texture()
sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader"] = asset_loader_mod
def _install_panel_stubs(aethergrid):
sectioned_mod = types.ModuleType(SECTIONED_PANEL_MODULE_NAME)
sectioned_mod.SectionedTileLayout = type("SectionedTileLayout", (), {})
sectioned_mod.TileSection = type("TileSection", (), {})
_register_modules({
SECTIONED_PANEL_MODULE_NAME: sectioned_mod,
"openpilot.common.params": types.SimpleNamespace(Params=type("Params", (), {}), UnknownKeyName=Exception),
MODULE_NAME: aethergrid,
})
def _import_module(module_name):
_install_aethergrid_stubs()
_clear_modules(module_name)
return importlib.import_module(module_name)
def _import_aethergrid():
return _import_module(MODULE_NAME)
def _import_panel(monkeypatch_module=None):
aethergrid = _import_aethergrid()
_install_panel_stubs(aethergrid)
_clear_modules(PANEL_MODULE_NAME)
return importlib.import_module(PANEL_MODULE_NAME)
def _import_real_sectioned_panel():
_install_aethergrid_stubs()
_clear_modules(SECTIONED_PANEL_MODULE_NAME)
return importlib.import_module(SECTIONED_PANEL_MODULE_NAME)
class RenderSpy:
def __init__(self):
self.rects = []
self.parent_rect = None
def render(self, rect):
self.rects.append(rect)
def show_event(self):
pass
def hide_event(self):
pass
def set_parent_rect(self, rect):
self.parent_rect = rect
class TestAethergridContracts(unittest.TestCase):
def test_aethergrid_module_imports_with_headless_stubs(self):
mod = _import_aethergrid()
self.assertTrue(hasattr(mod, "TileGrid"))
self.assertTrue(hasattr(mod, "HubTile"))
self.assertTrue(hasattr(mod, "ToggleTile"))
self.assertTrue(hasattr(mod, "ValueTile"))
self.assertTrue(hasattr(mod, "RadioTileGroup"))
self.assertTrue(hasattr(mod, "AetherSliderDialog"))
def test_tile_grid_column_contract_stays_stable(self):
mod = _import_aethergrid()
grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap)
self.assertEqual(grid.get_column_count(1), 1)
self.assertEqual(grid.get_column_count(2), 2)
self.assertEqual(grid.get_column_count(3), 3)
self.assertEqual(grid.get_column_count(4), 2)
self.assertEqual(grid.get_column_count(5), 3)
self.assertEqual(grid.get_column_count(7), 4)
def test_build_list_panel_frame_contract(self):
mod = _import_aethergrid()
frame = mod.build_list_panel_frame(mod.rl.Rectangle(0, 0, 1920, 1080))
self.assertGreater(frame.shell.width, 0)
self.assertEqual(frame.header.height, mod.AETHER_LIST_METRICS.header_height)
self.assertGreater(frame.scroll.height, 0)
def test_hit_rect_keeps_touch_slop_after_surface_flattening(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
tile.set_rect(mod.rl.Rectangle(100, 200, 300, 150))
hit = tile._hit_rect
self.assertGreater(hit.width, tile._rect.width)
self.assertGreater(hit.height, tile._rect.height)
def test_aether_tile_uses_single_planar_face_contract(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = tile._surface_rect(mod.rl.Rectangle(0, 0, 320, 160))
self.assertLess(face.width, 320)
self.assertLess(face.height, 160)
self.assertGreaterEqual(face.x, 0)
self.assertGreaterEqual(face.y, 0)
def test_aether_tile_surface_rect_snaps_to_integer_pixels(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = tile._surface_rect(mod.rl.Rectangle(0.5, 0.5, 320.25, 160.75))
self.assertEqual(face.x, round(face.x))
self.assertEqual(face.y, round(face.y))
self.assertEqual(face.width, round(face.width))
self.assertEqual(face.height, round(face.height))
def test_aether_tile_preserves_substrate_color_attribute_for_compatibility(self):
mod = _import_aethergrid()
substrate = mod.hex_to_color("#101820")
tile = mod.AetherTile(surface_color="#3B82F6", substrate_color=substrate)
self.assertIs(tile.substrate_color, substrate)
def test_hub_tile_preserves_status_progress_api(self):
mod = _import_aethergrid()
tile = mod.HubTile("Driving Controls", "Desc", bg_color="#3B82F6", get_status=lambda: "Download 50%")
self.assertEqual(tile.get_status(), "Download 50%")
def test_tile_stack_layout_keeps_full_content_block_inside_face(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = mod.rl.Rectangle(0, 0, 320, 180)
layout = tile._measure_tile_stack(face, icon_height=60, title_lines=2, title_size=28, primary_size=30, desc_lines=2, desc_size=18)
self.assertGreaterEqual(layout["top"], 0)
self.assertLessEqual(layout["desc_bottom"], face.height)
def test_tile_grid_reflows_to_wider_tiles_when_width_is_tight(self):
mod = _import_aethergrid()
grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap)
spies = [RenderSpy() for _ in range(7)]
for spy in spies:
grid.add_tile(spy)
grid.render(mod.rl.Rectangle(0, 0, 700, 500))
self.assertTrue(spies[0].rects)
self.assertGreater(spies[0].rects[0].width, 300)
def test_toggle_and_value_tiles_keep_enabled_contract(self):
mod = _import_aethergrid()
toggle = mod.ToggleTile("Feature", lambda: True, lambda _v: None, is_enabled=lambda: False)
value = mod.ValueTile("Value", lambda: "42", lambda: None, is_enabled=lambda: False)
self.assertFalse(toggle.enabled)
self.assertFalse(value.enabled)
def test_disabled_value_tile_does_not_trigger_click(self):
mod = _import_aethergrid()
on_click = MagicMock()
value = mod.ValueTile("Value", lambda: "42", on_click, is_enabled=lambda: False)
value.set_rect(mod.rl.Rectangle(0, 0, 300, 150))
value._handle_mouse_press(types.SimpleNamespace(x=10, y=10))
value._handle_mouse_release(types.SimpleNamespace(x=10, y=10))
on_click.assert_not_called()
def test_slider_dialog_keeps_color_and_callback_contract(self):
mod = _import_aethergrid()
captured = []
dialog = mod.AetherSliderDialog("Test", 0, 10, 1, 5, lambda result, value: captured.append((result, value)), color="#8B5CF6")
self.assertGreaterEqual(dialog._color.r, 0)
self.assertEqual(dialog._current_val, 5)
def test_radio_tile_group_preserves_selection_api(self):
mod = _import_aethergrid()
group = mod.RadioTileGroup("", ["A", "B", "C"], 1, lambda idx: None)
group.set_index(2)
self.assertEqual(group.current_index, 2)
def test_zero_span_sliders_do_not_divide_by_zero(self):
mod = _import_aethergrid()
slider = mod.AetherSlider(5, 5, 1, 5, lambda _v: None)
thumb_x = slider._get_thumb_x(mod.rl.Rectangle(0, 0, 320, 80))
self.assertGreaterEqual(thumb_x, 0)
def test_panel_module_can_import_against_refactored_aethergrid(self):
panel_mod = _import_panel()
self.assertTrue(hasattr(panel_mod, "StarPilotPanel"))
self.assertTrue(hasattr(panel_mod, "create_tile_panel"))
def test_sectioned_layout_short_height_uses_scrollable_compact_profile(self):
mod = _import_aethergrid()
sectioned_mod = _import_real_sectioned_panel()
layout = sectioned_mod.SectionedTileLayout()
grid = mod.TileGrid(columns=2, padding=mod.SPACING.tile_gap, uniform_width=True)
for _ in range(6):
grid.add_tile(RenderSpy())
layout.set_sections([sectioned_mod.TileSection("Test", grid)])
title_h, title_gap, section_gap, row_h = layout._layout_profile(mod.rl.Rectangle(0, 0, 800, 220), layout._sections)
self.assertGreaterEqual(row_h, 0)
self.assertLessEqual(title_h, layout._title_height)
def test_slider_dialog_on_change_callback(self):
mod = _import_aethergrid()
captured_changes = []
captured_close = []
dialog = mod.AetherSliderDialog(
"Test", 0, 10, 1, 5,
on_close=lambda res, val: captured_close.append((res, val)),
color="#8B5CF6",
on_change=lambda val: captured_changes.append(val)
)
# Run render to compute button and track rects
dialog._render(mod.rl.Rectangle(0, 0, 1920, 1080))
# Mock mouse position hitting the plus rect and press it
plus_center = mod.rl.Vector2(
dialog._plus_rect.x + dialog._plus_rect.width / 2,
dialog._plus_rect.y + dialog._plus_rect.height / 2
)
# Mock collision detection to return True when checking the plus button
old_collision = mod.rl.check_collision_point_rec
def mock_collision(point, rec):
if rec == dialog._plus_rect:
return True
return False
mod.rl.check_collision_point_rec = mock_collision
try:
dialog._handle_mouse_press(plus_center)
dialog._handle_mouse_release(plus_center)
finally:
mod.rl.check_collision_point_rec = old_collision
self.assertEqual(dialog._current_val, 6)
self.assertEqual(captured_changes, [6])
if __name__ == "__main__":
unittest.main()