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/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_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: 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