From bc6ee6ebeb86f2a416061e63cc6569bd8b7323eb Mon Sep 17 00:00:00 2001 From: Andrew McIntosh Date: Fri, 10 Oct 2025 16:15:49 -0400 Subject: [PATCH 1/2] test: Re-add basic test for tui module Readding tests for the TUI module. Previous work was merged after the record_history changes, causing failures in main. This should resolve. As with the original work, rhis adds some basic tests to get coverage on some of the common rendering code. Full test coverage will take a bit more effort. --- pyproject.toml | 1 + tests/test_tui.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/test_tui.py diff --git a/pyproject.toml b/pyproject.toml index 19b28d0..afe8def 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "black>=23.0.0", "flake8>=6.0.0", "freezegun>=1.5.5", + "pytest-asyncio>=0.24.0", ] [project.scripts] diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..f02b760 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,81 @@ +from unittest.mock import ANY, patch + +import pytest + +from alix.models import Alias +from alix.shell_integrator import ShellIntegrator +from alix.tui import AliasManager + + +@pytest.mark.asyncio +@patch.object(ShellIntegrator, "apply_single_alias") +@patch.object(AliasManager, "notify") +@patch("alix.tui.AliasStorage", autospec=True) +async def test_add_alias(mock_storage, mock_notify, mock_apply, alias_min): + mock_storage.return_value.add.return_value = True + mock_storage.return_value.get.return_value = None + mock_apply.return_value = (True, "✓ Applied alias 'alix-test-echo' to .zshrc") + alias_min.created_at = ANY + + app = AliasManager() + + async with app.run_test(size=(90, 30)) as pilot: + await pilot.click("#btn-add") + await pilot.click("#name") + await pilot.press(*list("alix-test-echo")) + await pilot.click("#command") + await pilot.press(*list("alix test working!")) + await pilot.click("#description") + await pilot.press(*list("alix test shortcut")) + await pilot.click("#create") + + await pilot.pause() # Wait for async operations to complete + + mock_notify.assert_any_call( + "Created and applied 'alix-test-echo'", severity="information" + ) + mock_notify.assert_any_call("Alias added and applied successfully") + mock_storage.return_value.add.assert_called_once_with(alias_min) + mock_apply.assert_called_once_with(alias_min) + + +@pytest.mark.asyncio +@patch.object(ShellIntegrator, "apply_single_alias") +@patch.object(AliasManager, "notify") +@patch("alix.tui.AliasStorage", autospec=True) +async def test_edit_alias(mock_storage, mock_notify, mock_apply, alias_min): + mock_storage.return_value.aliases = {} + mock_apply.return_value = (True, "✓ Applied alias 'alix-test-echo-2' to .zshrc") + alias_min.created_at = ANY + + new_alias = Alias( + name="alix-test-echo-2", + command="alix test changed!", + description="alix test shortcut changed", + created_at=alias_min.created_at, + ) + + app = AliasManager() + app.selected_alias = alias_min + + async with app.run_test(size=(90, 30)) as pilot: + await pilot.click("#btn-edit") + await pilot.click("#name") + await pilot.press(*list(["delete"] * len(alias_min.name))) + await pilot.press(*list(new_alias.name)) + await pilot.click("#command") + await pilot.press(*list(["delete"] * len(alias_min.command))) + await pilot.press(*list(new_alias.command)) + await pilot.click("#description") + await pilot.press(*list(["delete"] * len(alias_min.description))) + await pilot.press(*list(new_alias.description)) + await pilot.click("#update") + + await pilot.pause() # Wait for async operations to complete + + mock_notify.assert_any_call("Alias updated and applied successfully") + + mock_storage.return_value.remove.assert_called_once_with(alias_min.name) + mock_storage.return_value.save.assert_called_once() + mock_apply.assert_called_once_with(new_alias) + assert mock_storage.return_value.aliases["alix-test-echo-2"] == new_alias From 0ba7eae5924f4bc6ce6c5a76e5547067145e50af Mon Sep 17 00:00:00 2001 From: Andrew McIntosh Date: Fri, 10 Oct 2025 16:26:27 -0400 Subject: [PATCH 2/2] Change typing in return types These are on older Python versions (3.8, 3.9) that had lesser typing support. Failed tests: ``` alix/render.py:12: in Render def _split_keep_ws(self, s: str) -> list[str | Any]: E TypeError: unsupported operand type(s) for |: 'type' and '_SpecialForm' ``` ``` alix/shell_integrator.py:61: in ShellIntegrator def preview_aliases(self, target_file: Optional[Path] = None) -> tuple[str, str]: E TypeError: 'type' object is not subscriptable ``` --- alix/porter.py | 32 +++++++-------- alix/render.py | 8 ++-- alix/shell_integrator.py | 16 ++++---- tests/test_storage.py | 86 ++++++++++++++++++++-------------------- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/alix/porter.py b/alix/porter.py index cecdd35..77a617c 100644 --- a/alix/porter.py +++ b/alix/porter.py @@ -2,7 +2,7 @@ import yaml from pathlib import Path from datetime import datetime -from typing import List, Dict, Any +from typing import List, Dict, Any, Tuple from alix.models import Alias from alix.storage import AliasStorage @@ -18,7 +18,7 @@ def export_to_dict(self, aliases: List[Alias] = None, tag_filter: str = None) -> """Export aliases to a dictionary format""" if aliases is None: aliases = self.storage.list_all() - + # Apply tag filter if specified if tag_filter: aliases = [alias for alias in aliases if tag_filter in alias.tags] @@ -29,13 +29,13 @@ def export_to_dict(self, aliases: List[Alias] = None, tag_filter: str = None) -> "count": len(aliases), "aliases": [alias.to_dict() for alias in aliases], } - + if tag_filter: export_data["tag_filter"] = tag_filter - + return export_data - def export_to_file(self, filepath: Path, format: str = "json", tag_filter: str = None) -> tuple[bool, str]: + def export_to_file(self, filepath: Path, format: str = "json", tag_filter: str = None) -> Tuple[bool, str]: """Export aliases to a file""" data = self.export_to_dict(tag_filter=tag_filter) @@ -54,7 +54,7 @@ def export_to_file(self, filepath: Path, format: str = "json", tag_filter: str = except Exception as e: return False, f"Export failed: {str(e)}" - def import_from_file(self, filepath: Path, merge: bool = False, tag_filter: str = None) -> tuple[bool, str]: + def import_from_file(self, filepath: Path, merge: bool = False, tag_filter: str = None) -> Tuple[bool, str]: """Import aliases from a file""" if not filepath.exists(): return False, f"File not found: {filepath}" @@ -75,12 +75,12 @@ def import_from_file(self, filepath: Path, merge: bool = False, tag_filter: str for alias_data in data["aliases"]: alias = Alias.from_dict(alias_data) - + # Apply tag filter if specified if tag_filter and tag_filter not in alias.tags: tag_filtered += 1 continue - + if merge or alias.name not in self.storage.aliases: self.storage.aliases[alias.name] = alias imported += 1 @@ -100,20 +100,20 @@ def import_from_file(self, filepath: Path, merge: bool = False, tag_filter: str except Exception as e: return False, f"Import failed: {str(e)}" - def export_by_tags(self, tags: List[str], filepath: Path, format: str = "json", match_all: bool = False) -> tuple[bool, str]: + def export_by_tags(self, tags: List[str], filepath: Path, format: str = "json", match_all: bool = False) -> Tuple[bool, str]: """Export aliases that match any (or all) of the specified tags""" aliases = self.storage.list_all() - + if match_all: # Match aliases that have ALL specified tags filtered_aliases = [alias for alias in aliases if all(tag in alias.tags for tag in tags)] else: # Match aliases that have ANY of the specified tags filtered_aliases = [alias for alias in aliases if any(tag in alias.tags for tag in tags)] - + if not filtered_aliases: return False, f"No aliases found matching tags: {', '.join(tags)}" - + export_data = { "version": "1.0", "exported_at": datetime.now().isoformat(), @@ -122,7 +122,7 @@ def export_by_tags(self, tags: List[str], filepath: Path, format: str = "json", "count": len(filtered_aliases), "aliases": [alias.to_dict() for alias in filtered_aliases] } - + try: if format == "yaml": with open(filepath, "w") as f: @@ -142,19 +142,19 @@ def get_tag_statistics(self) -> Dict[str, Any]: aliases = self.storage.list_all() tag_counts = {} tag_combinations = {} - + for alias in aliases: # Count individual tags for tag in alias.tags: tag_counts[tag] = tag_counts.get(tag, 0) + 1 - + # Count tag combinations (pairs) if len(alias.tags) >= 2: for i, tag1 in enumerate(alias.tags): for tag2 in alias.tags[i+1:]: combo = tuple(sorted([tag1, tag2])) tag_combinations[combo] = tag_combinations.get(combo, 0) + 1 - + return { "total_tags": len(tag_counts), "total_aliases": len(aliases), diff --git a/alix/render.py b/alix/render.py index a2ae10d..930ff59 100644 --- a/alix/render.py +++ b/alix/render.py @@ -1,7 +1,7 @@ import re from difflib import SequenceMatcher -from typing import Any, Literal +from typing import Any, List, Literal, Union from rich.table import Table from rich.console import Console @@ -9,17 +9,17 @@ from rich import box class Render: - def _split_keep_ws(self, s: str) -> list[str | Any]: + def _split_keep_ws(self, s: str) -> List[Union[str, Any]]: return re.split(r'(\s+)', s) def _word_level_text(self, left: str, right: str, side: Literal["left", "right"]) -> Text: """ Give color-coded output for additions/deletions using rich.Text, - with token highlight base on the left vs right comparison. + with token highlight base on the left vs right comparison. """ if side not in ("left", "right"): raise ValueError("side must be 'left' or 'right'") - + left_tokens = self._split_keep_ws(left) right_tokens = self._split_keep_ws(right) sm = SequenceMatcher(None, left_tokens, right_tokens) diff --git a/alix/shell_integrator.py b/alix/shell_integrator.py index 4835b38..98980e8 100644 --- a/alix/shell_integrator.py +++ b/alix/shell_integrator.py @@ -1,7 +1,7 @@ import shutil from pathlib import Path from datetime import datetime -from typing import Optional +from typing import Optional, Tuple from alix.shell_detector import ShellDetector, ShellType from alix.storage import AliasStorage @@ -58,8 +58,8 @@ def export_aliases(self, shell_type: ShellType) -> str: return "\n".join(lines) - def preview_aliases(self, target_file: Optional[Path] = None) -> tuple[str, str]: - """Get old and new config for dry-run""" + def preview_aliases(self, target_file: Optional[Path] = None) -> Tuple[str, str]: + """Get old and new config for dry-run""" # Read current config if target_file == None: content = "" @@ -80,10 +80,10 @@ def preview_aliases(self, target_file: Optional[Path] = None) -> tuple[str, str] aliases_section += f"# Generated by alix on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" aliases_section += self.export_aliases(self.shell_type) aliases_section += f"\n{self.ALIX_MARKER_END}\n" - + return (old_alix, aliases_section) - - def apply_aliases(self, target_file: Optional[Path] = None) -> tuple[bool, str]: + + def apply_aliases(self, target_file: Optional[Path] = None) -> Tuple[bool, str]: """Apply aliases to shell configuration file""" if not target_file: target_file = self.get_target_file() @@ -126,7 +126,7 @@ def apply_aliases(self, target_file: Optional[Path] = None) -> tuple[bool, str]: # NEW METHOD: apply_single_alias def apply_single_alias( self, alias: Alias, auto_reload: bool = True - ) -> tuple[bool, str]: + ) -> Tuple[bool, str]: """Apply a single alias immediately to the current shell session""" target_file = self.get_target_file() @@ -192,7 +192,7 @@ def reload_shell_config(self) -> bool: # NEW METHOD: install_completions - def install_completions(self, script_content: str, shell_type: Optional[ShellType] = None) -> tuple[bool, str]: + def install_completions(self, script_content: str, shell_type: Optional[ShellType] = None) -> Tuple[bool, str]: """Install shell completion script and source it from the user's shell config. For bash and zsh, writes a script under ~/.config/alix/completions and ensures diff --git a/tests/test_storage.py b/tests/test_storage.py index 3ffb4c1..1aa3cc5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,5 +1,5 @@ from pathlib import Path -from unittest.mock import ANY, Mock, mock_open, patch +from unittest.mock import Mock, mock_open, patch, call from freezegun import freeze_time @@ -7,58 +7,58 @@ @patch.object(AliasStorage, "load") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_init__default_path(mock_mkdir, mock_load): AliasStorage() - expected_storage_path = Path.home() / ".alix" - expected_backup_path = expected_storage_path / "backups" - - mock_mkdir.assert_any_call(expected_storage_path, ANY) - mock_mkdir.assert_any_call(expected_backup_path, ANY) + mock_mkdir.assert_has_calls([ + call(exist_ok=True), + call(exist_ok=True), + call(parents=True, exist_ok=True) + ]) mock_load.assert_called_once() @patch.object(AliasStorage, "load") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_init__custom_path(mock_mkdir, mock_load): expected_storage_path = Path("/some/path/file.json") - expected_backup_path = Path("/some/path/backups") AliasStorage(storage_path=expected_storage_path) - mock_mkdir.assert_any_call(expected_backup_path, ANY) + mock_mkdir.assert_has_calls([ + call(exist_ok=True), + call(parents=True, exist_ok=True) + ]) mock_load.assert_called_once() @freeze_time("2025-10-24 21:01:01") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") @patch("alix.storage.shutil") def test_create_backup(mock_shutil, mock_mkdir): storage = AliasStorage() storage.aliases = {"alix-test-echo": "alix test working!"} - with ( - patch("pathlib.Path.exists", autospec=True) as mock_exists, - patch("pathlib.Path.glob", autospec=True) as mock_glob, - patch("pathlib.Path.unlink", autospec=True) as mock_unlink, - ): - mock_exists.return_value = True - mock_glob.return_value = [ - Path("alias_20251024_200000.json"), - Path("alias_20251023_200000.json"), - Path("alias_20251022_200000.json"), - Path("alias_20251021_200000.json"), - Path("alias_20251020_200000.json"), - Path("alias_20251019_200000.json"), - Path("alias_20251018_200000.json"), - Path("alias_20251017_200000.json"), - Path("alias_20251016_200000.json"), - Path("alias_20251015_200000.json"), - Path("alias_20251014_200000.json"), - ] - - storage.create_backup() + with patch("pathlib.Path.exists", autospec=True) as mock_exists: + with patch("pathlib.Path.glob", autospec=True) as mock_glob: + with patch("pathlib.Path.unlink", autospec=True) as mock_unlink: + mock_exists.return_value = True + mock_glob.return_value = [ + Path("alias_20251024_200000.json"), + Path("alias_20251023_200000.json"), + Path("alias_20251022_200000.json"), + Path("alias_20251021_200000.json"), + Path("alias_20251020_200000.json"), + Path("alias_20251019_200000.json"), + Path("alias_20251018_200000.json"), + Path("alias_20251017_200000.json"), + Path("alias_20251016_200000.json"), + Path("alias_20251015_200000.json"), + Path("alias_20251014_200000.json"), + ] + + storage.create_backup() mock_shutil.copy2.assert_called_once_with( Path.home() / Path(".alix/aliases.json"), @@ -67,7 +67,7 @@ def test_create_backup(mock_shutil, mock_mkdir): mock_unlink.assert_called_once_with(Path("alias_20251014_200000.json")) -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") @patch("alix.storage.shutil") def test_create_backup__no_aliases_file(mock_shutil, mock_mkdir): storage = AliasStorage(Path("/tmp/nothing/aliases.json")) @@ -78,7 +78,7 @@ def test_create_backup__no_aliases_file(mock_shutil, mock_mkdir): mock_shutil.copy2.assert_not_called() -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_load(mock_mkdir, storage_file_raw_data, alias): storage = AliasStorage() @@ -95,7 +95,7 @@ def test_load(mock_mkdir, storage_file_raw_data, alias): assert storage.aliases["alix-test-echo"] == alias -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_load__corrupted_file(mock_mkdir): expected_data = '{"alix-test-echo": zzzzzz}' mocked_open = mock_open(read_data=expected_data) @@ -117,7 +117,7 @@ def test_load__corrupted_file(mock_mkdir): @patch("alix.storage.json") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_add(mock_mkdir, mock_json, alias, storage_file_data): mocked_open = mock_open() mock_backup = Mock() @@ -137,7 +137,7 @@ def test_add(mock_mkdir, mock_json, alias, storage_file_data): @patch("alix.storage.json") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_add__alias_exists(mock_mkdir, mock_json, alias): mock_backup = Mock() @@ -154,7 +154,7 @@ def test_add__alias_exists(mock_mkdir, mock_json, alias): @patch("alix.storage.json") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_remove(mock_mkdir, mock_json, alias): mocked_open = mock_open() mock_backup = Mock() @@ -173,7 +173,7 @@ def test_remove(mock_mkdir, mock_json, alias): @patch("alix.storage.json") -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_remove__alias_absent(mock_mkdir, mock_json, alias): mock_backup = Mock() @@ -188,7 +188,7 @@ def test_remove__alias_absent(mock_mkdir, mock_json, alias): mock_json.dump.assert_not_called() -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_get(mock_mkdir, alias): storage = AliasStorage() storage.aliases[alias.name] = alias @@ -196,7 +196,7 @@ def test_get(mock_mkdir, alias): assert storage.get(alias.name) == alias -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") def test_list_all(mock_mkdir, alias_list): storage = AliasStorage() storage.aliases.clear() # Clear any loaded aliases for test isolation @@ -208,7 +208,7 @@ def test_list_all(mock_mkdir, alias_list): assert list_all[1] == alias_list[1] -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") @patch("alix.storage.shutil") def test_restore_latest_backup(mock_shutil, mock_mkdir): with patch("pathlib.Path.glob", autospec=True) as mock_glob: @@ -231,7 +231,7 @@ def test_restore_latest_backup(mock_shutil, mock_mkdir): ) -@patch("os.mkdir") +@patch("alix.storage.Path.mkdir") @patch("alix.storage.shutil") def test_restore_latest_backup__no_backups(mock_shutil, mock_mkdir): with patch("pathlib.Path.glob", autospec=True) as mock_glob: