diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 5ac0fe78..b21e4c14 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Callable, override +from collections.abc import Callable +from typing import Any, override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu @@ -70,25 +71,22 @@ class GlobalMenu(AbstractMenu[None]): - [•] (Yellow) for unconfigured optional items """ if item.read_only or item.key in (SpecialMenuKey.SAVE.value, SpecialMenuKey.INSTALL.value, SpecialMenuKey.ABORT.value): - return "" + return '' if item.key == 'auth_config': auth_config: AuthenticationConfiguration | None = item.value - is_auth_valid = ( - auth_config is not None - and (auth_config.root_enc_password is not None or auth_config.has_superuser()) - ) + is_auth_valid = auth_config is not None and (auth_config.root_enc_password is not None or auth_config.has_superuser()) if is_auth_valid: - return "[bold green][✓][/bold green] " - return "[bold red][!][/bold red] " + return '[bold green][✓][/bold green] ' + return '[bold red][!][/bold red] ' # Standard mandatory or configured item check if item.has_value(): - return "[bold green][✓][/bold green] " + return '[bold green][✓][/bold green] ' elif item.mandatory: - return "[bold red][!][/bold red] " + return '[bold red][!][/bold red] ' else: - return "[bold yellow][•][/bold yellow] " + return '[bold yellow][•][/bold yellow] ' def _update_item_labels(self) -> None: """ @@ -101,9 +99,9 @@ class GlobalMenu(AbstractMenu[None]): if item.key in new_options: base_title = new_options[item.key] prefix = self._get_status_prefix(item) - item.text = f"{prefix}{base_title}" + item.text = f'{prefix}{base_title}' - def _wrap_action(self, key: str, action: Callable) -> Callable: + def _wrap_action(self, key: str, action: Callable[..., Any]) -> Callable[..., Any]: async def wrapper(*args, **kwargs) -> Any: if inspect.iscoroutinefunction(action): result = await action(*args, **kwargs) @@ -111,15 +109,15 @@ class GlobalMenu(AbstractMenu[None]): result = action(*args, **kwargs) if inspect.isawaitable(result): result = await result - + item = self._item_group.find_by_key(key) if item: item.value = result - + self._update_item_labels() - + return result - + return wrapper def _get_menu_options(self, wrap_actions: bool = True) -> list[MenuItem]: diff --git a/tests/test_global_menu_ui.py b/tests/test_global_menu_ui.py new file mode 100644 index 00000000..cb1435d3 --- /dev/null +++ b/tests/test_global_menu_ui.py @@ -0,0 +1,154 @@ +import asyncio +import inspect +from unittest.mock import MagicMock + + +class MockSpecialMenuKey: + SAVE = 'save' + INSTALL = 'install' + ABORT = 'abort' + + +class MockMenuItem: + def __init__(self, key=None, read_only=False, mandatory=False, value=None): + self.key = key + self.read_only = read_only + self.mandatory = mandatory + self.value = value + + def has_value(self): + return self.value is not None + + +def isolated_get_status_prefix(item: MockMenuItem) -> str: + special_keys = (MockSpecialMenuKey.SAVE, MockSpecialMenuKey.INSTALL, MockSpecialMenuKey.ABORT) + + if item.read_only or item.key in special_keys: + return '' + + if item.key == 'auth_config': + auth_config = item.value + has_root = getattr(auth_config, 'root_enc_password', None) is not None + has_super = getattr(auth_config, 'has_superuser', lambda: False)() if auth_config else False + + if auth_config is not None and (has_root or has_super): + return '[bold green][✓][/bold green] ' + return '[bold red][!][/bold red] ' + + if item.has_value(): + return '[bold green][✓][/bold green] ' + elif item.mandatory: + return '[bold red][!][/bold red] ' + else: + return '[bold yellow][•][/bold yellow] ' + + +def isolated_wrap_action(item_dictionary, update_callback, key, action): + + async def wrapper(*args, **kwargs): + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + + if key in item_dictionary: + item_dictionary[key].value = result + + update_callback() + return result + + return wrapper + + +class TestPrefixLogic: + def test_special_keys_have_no_prefix(self): + item = MockMenuItem(key=MockSpecialMenuKey.SAVE) + assert isolated_get_status_prefix(item) == '' + + def test_read_only_has_no_prefix(self): + item = MockMenuItem(read_only=True) + assert isolated_get_status_prefix(item) == '' + + def test_configured_item_has_checkmark(self): + item = MockMenuItem(key='hostname', value='archlinux') + prefix = isolated_get_status_prefix(item) + assert '[✓]' in prefix + assert 'green' in prefix + + def test_missing_mandatory_item_has_warning(self): + item = MockMenuItem(key='disk_config', mandatory=True, value=None) + prefix = isolated_get_status_prefix(item) + assert '[!]' in prefix + assert 'red' in prefix + + def test_missing_optional_item_has_dot(self): + item = MockMenuItem(key='network_config', mandatory=False, value=None) + prefix = isolated_get_status_prefix(item) + assert '[•]' in prefix + assert 'yellow' in prefix + + +class TestAuthConfigLogic: + def test_auth_missing_entirely(self): + item = MockMenuItem(key='auth_config', value=None) + assert '[!]' in isolated_get_status_prefix(item) + + def test_auth_invalid_state(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = None + mock_auth.has_superuser.return_value = False + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[!]' in isolated_get_status_prefix(item) + + def test_auth_valid_root(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = 'hashed' + mock_auth.has_superuser.return_value = False + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[✓]' in isolated_get_status_prefix(item) + + def test_auth_valid_superuser(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = None + mock_auth.has_superuser.return_value = True + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[✓]' in isolated_get_status_prefix(item) + + +class TestActionWrapperLogic: + def test_async_action_updates_value(self): + mock_item = MockMenuItem(key='disk') + item_dict = {'disk': mock_item} + update_spy = MagicMock() + + async def dummy_async_action(): + return 'new_disk_layout' + + wrapped = isolated_wrap_action(item_dict, update_spy, 'disk', dummy_async_action) + + result = asyncio.run(wrapped()) + + assert result == 'new_disk_layout' + assert mock_item.value == 'new_disk_layout' + update_spy.assert_called_once() + + def test_sync_action_updates_value(self): + mock_item = MockMenuItem(key='hostname') + item_dict = {'hostname': mock_item} + update_spy = MagicMock() + + def dummy_sync_action(): + return 'my-pc' + + wrapped = isolated_wrap_action(item_dict, update_spy, 'hostname', dummy_sync_action) + + result = asyncio.run(wrapped()) + + assert result == 'my-pc' + assert mock_item.value == 'my-pc' + update_spy.assert_called_once()