diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c9b4dbb..8726fdf6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { "python.testing.pytestArgs": [ + "-vv", "--color=yes", "-s", "tests/pytest", - "-vv", "--color=yes", "-s" + "apps/", ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, diff --git a/apps/ttkode/pyproject.toml b/apps/ttkode/pyproject.toml index 06e34a23..31f70d58 100644 --- a/apps/ttkode/pyproject.toml +++ b/apps/ttkode/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ 'pyTermTk>=0.48.1-a0', + 'pytest', 'appdirs', 'copykitten', 'pygments' diff --git a/apps/ttkode/tests/README.md b/apps/ttkode/tests/README.md new file mode 100644 index 00000000..366c6a44 --- /dev/null +++ b/apps/ttkode/tests/README.md @@ -0,0 +1,75 @@ +# ttkode Tests + +This directory contains the test suite for the ttkode application. + +## Test Structure + +- **conftest.py** - Pytest configuration and fixtures +- **test_plugin.py** - Tests for the plugin system (TTkodePlugin and related classes) +- **test_config.py** - Tests for configuration management (TTKodeCfg) +- **test_proxy.py** - Tests for proxy functionality (TTKodeViewerProxy, TTKodeProxy) +- **test_search_file.py** - Tests for file search functionality +- **test_helper.py** - Tests for helper functions (TTkodeHelper) + +## Running Tests + +### Run all tests + +```bash +cd apps/ttkode +pytest tests/ +``` + +### Run specific test file + +```bash +pytest tests/test_plugin.py +pytest tests/test_config.py +pytest tests/test_search_file.py +``` + +### Run with verbose output + +```bash +pytest tests/ -v +``` + +### Run with coverage + +```bash +pytest tests/ --cov=ttkode --cov-report=html +``` + +### Run specific test + +```bash +pytest tests/test_plugin.py::TestTTkodePlugin::test_plugin_basic_creation +``` + +## Test Coverage + +The test suite covers: + +- **Plugin System**: Creation, registration, callbacks, widget management +- **Configuration**: Default values, saving, loading, path management +- **Proxy**: Viewer proxy functionality and file name management +- **File Search**: Pattern matching, .gitignore support, directory walking +- **Helpers**: Plugin loading and execution + +## Notes + +Some tests are marked as skipped (`@pytest.mark.skip`) because they require: +- Full TTKode UI instance setup +- Plugin folder and file system setup +- Complex UI mocking infrastructure + +These integration tests can be completed when the UI testing infrastructure is available. + +## Requirements + +Tests require: +- pytest +- pyTermTk +- Python 3.9+ + +All dependencies are listed in the main `pyproject.toml` file. diff --git a/apps/ttkode/tests/conftest.py b/apps/ttkode/tests/conftest.py new file mode 100644 index 00000000..4c985200 --- /dev/null +++ b/apps/ttkode/tests/conftest.py @@ -0,0 +1,40 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +import os +import pytest + +# Add the library path for pyTermTk +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +# Add the ttkode package path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +@pytest.fixture(autouse=True) +def reset_plugin_instances(): + """Reset plugin instances before each test to avoid state leakage.""" + from ttkode.plugin import TTkodePlugin + original_instances = TTkodePlugin.instances.copy() + yield + TTkodePlugin.instances.clear() + TTkodePlugin.instances.extend(original_instances) diff --git a/apps/ttkode/tests/test_config.py b/apps/ttkode/tests/test_config.py new file mode 100644 index 00000000..b0b991f6 --- /dev/null +++ b/apps/ttkode/tests/test_config.py @@ -0,0 +1,130 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +from ttkode.app.cfg import TTKodeCfg +from ttkode import __version__ + + +class TestTTKodeCfg: + """Test the configuration management system.""" + + def test_default_values(self): + """Test that configuration has correct default values.""" + assert TTKodeCfg.version == __version__ + assert TTKodeCfg.name == "ttkode" + assert TTKodeCfg.cfgVersion == '1.0' + assert TTKodeCfg.pathCfg == "." + assert isinstance(TTKodeCfg.options, dict) + assert TTKodeCfg.maxsearches == 200 + + def test_options_is_dict(self): + """Test that options is a dictionary.""" + assert isinstance(TTKodeCfg.options, dict) + # Options can be modified + original_options = TTKodeCfg.options.copy() + TTKodeCfg.options['test_key'] = 'test_value' + assert TTKodeCfg.options['test_key'] == 'test_value' + # Restore original state + TTKodeCfg.options = original_options + + def test_save_creates_directory(self, tmp_path): + """Test that save creates the configuration directory if it doesn't exist.""" + original_path = TTKodeCfg.pathCfg + TTKodeCfg.pathCfg = str(tmp_path / "config") + + # Directory shouldn't exist yet + assert not os.path.exists(TTKodeCfg.pathCfg) + + # Save should create it + TTKodeCfg.save() + + assert os.path.exists(TTKodeCfg.pathCfg) + assert os.path.isdir(TTKodeCfg.pathCfg) + + # Restore original path + TTKodeCfg.pathCfg = original_path + + def test_save_with_different_flags(self, tmp_path): + """Test save method with different flag combinations.""" + original_path = TTKodeCfg.pathCfg + TTKodeCfg.pathCfg = str(tmp_path / "config2") + + # Test with all flags + TTKodeCfg.save(searches=True, filters=True, colors=True, options=True) + assert os.path.exists(TTKodeCfg.pathCfg) + + # Test with individual flags + TTKodeCfg.save(searches=False, filters=False, colors=False, options=True) + TTKodeCfg.save(searches=True, filters=False, colors=False, options=False) + TTKodeCfg.save(searches=False, filters=True, colors=False, options=False) + TTKodeCfg.save(searches=False, filters=False, colors=True, options=False) + + # Restore original path + TTKodeCfg.pathCfg = original_path + + def test_maxsearches_value(self): + """Test that maxsearches has a reasonable default value.""" + assert isinstance(TTKodeCfg.maxsearches, int) + assert TTKodeCfg.maxsearches > 0 + assert TTKodeCfg.maxsearches == 200 + + def test_version_matches_package_version(self): + """Test that config version matches package version.""" + from ttkode import __version__ as pkg_version + assert TTKodeCfg.version == pkg_version + + def test_config_path_modification(self, tmp_path): + """Test that pathCfg can be modified.""" + original_path = TTKodeCfg.pathCfg + new_path = str(tmp_path / "new_config") + + TTKodeCfg.pathCfg = new_path + assert TTKodeCfg.pathCfg == new_path + + # Restore original path + TTKodeCfg.pathCfg = original_path + + def test_options_persistence(self): + """Test that options can be set and retrieved.""" + original_options = TTKodeCfg.options.copy() + + # Set some options + test_options = { + 'editor.fontSize': 12, + 'editor.tabSize': 4, + 'theme': 'dark' + } + TTKodeCfg.options = test_options + + # Verify options are set + assert TTKodeCfg.options['editor.fontSize'] == 12 + assert TTKodeCfg.options['editor.tabSize'] == 4 + assert TTKodeCfg.options['theme'] == 'dark' + + # Restore original options + TTKodeCfg.options = original_options diff --git a/apps/ttkode/tests/test_helper.py b/apps/ttkode/tests/test_helper.py new file mode 100644 index 00000000..1a409e2d --- /dev/null +++ b/apps/ttkode/tests/test_helper.py @@ -0,0 +1,194 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +import TermTk as ttk +from ttkode.helper import TTkodeHelper +from ttkode.plugin import TTkodePlugin, TTkodePluginWidgetActivity, TTkodePluginWidgetPanel + + +class TestTTkodeHelper: + """Test the helper functions for plugin management.""" + + def test_get_plugins_empty(self): + """Test getting plugins when none are loaded.""" + # Clear existing plugins + TTkodePlugin.instances.clear() + + plugins = TTkodeHelper._getPlugins() + + assert plugins == [] + assert isinstance(plugins, list) + + def test_get_plugins_with_instances(self): + """Test getting plugins after some are created.""" + # Clear and create test plugins + TTkodePlugin.instances.clear() + + plugin1 = TTkodePlugin(name="TestPlugin1") + plugin2 = TTkodePlugin(name="TestPlugin2") + + plugins = TTkodeHelper._getPlugins() + + assert len(plugins) == 2 + assert plugin1 in plugins + assert plugin2 in plugins + + def test_get_plugins_returns_reference(self): + """Test that _getPlugins returns a reference to the instances list.""" + TTkodePlugin.instances.clear() + + plugins = TTkodeHelper._getPlugins() + + # Should be the same list object + assert plugins is TTkodePlugin.instances + + @pytest.mark.skip(reason="Requires plugin folder and file system setup") + def test_load_plugins_from_folder(self, tmp_path): + """Test loading plugins from a folder.""" + # This would require creating actual plugin files + # and modifying the plugin folder path + pass + + @pytest.mark.skip(reason="Requires full TTKode UI instance") + def test_run_plugins_with_activity_widgets(self): + """Test running plugins with activity widgets.""" + # This requires a full TTKode instance with activity bar + pass + + @pytest.mark.skip(reason="Requires full TTKode UI instance") + def test_run_plugins_with_panel_widgets(self): + """Test running plugins with panel widgets.""" + # This requires a full TTKode instance with panels + pass + + def test_plugin_init_execution(self): + """Test that plugin init callbacks are executed.""" + TTkodePlugin.instances.clear() + + init_executed = [] + + def init_callback(): + init_executed.append(True) + + plugin = TTkodePlugin(name="InitPlugin", init=init_callback) + + # Simulate what _loadPlugins does + for mod in TTkodePlugin.instances: + if mod.init is not None: + mod.init() + + assert len(init_executed) == 1 + + def test_plugin_apply_execution(self): + """Test that plugin apply callbacks are executed.""" + TTkodePlugin.instances.clear() + + apply_executed = [] + + def apply_callback(): + apply_executed.append(True) + + plugin = TTkodePlugin(name="ApplyPlugin", apply=apply_callback) + + # Simulate what _runPlugins does + for mod in TTkodePlugin.instances: + if mod.apply is not None: + mod.apply() + + assert len(apply_executed) == 1 + + def test_multiple_plugins_init(self): + """Test initializing multiple plugins.""" + TTkodePlugin.instances.clear() + + counters = {'plugin1': 0, 'plugin2': 0, 'plugin3': 0} + + def make_init(name): + def init_cb(): + counters[name] += 1 + return init_cb + + plugin1 = TTkodePlugin(name="Plugin1", init=make_init('plugin1')) + plugin2 = TTkodePlugin(name="Plugin2", init=make_init('plugin2')) + plugin3 = TTkodePlugin(name="Plugin3", init=make_init('plugin3')) + + # Execute all init callbacks + for mod in TTkodePlugin.instances: + if mod.init is not None: + mod.init() + + assert counters['plugin1'] == 1 + assert counters['plugin2'] == 1 + assert counters['plugin3'] == 1 + + def test_plugin_with_widgets_detection(self): + """Test detecting plugin widgets.""" + TTkodePlugin.instances.clear() + + activity_widget = TTkodePluginWidgetActivity( + widget=ttk.TTkWidget(), + activityName="TestActivity", + icon=ttk.TTkString("📁") + ) + + panel_widget = TTkodePluginWidgetPanel( + widget=ttk.TTkWidget(), + panelName="TestPanel" + ) + + plugin = TTkodePlugin( + name="WidgetPlugin", + widgets=[activity_widget, panel_widget] + ) + + # Verify plugin has widgets + assert len(plugin.widgets) == 2 + + # Count widget types + activity_count = sum(1 for w in plugin.widgets if isinstance(w, TTkodePluginWidgetActivity)) + panel_count = sum(1 for w in plugin.widgets if isinstance(w, TTkodePluginWidgetPanel)) + + assert activity_count == 1 + assert panel_count == 1 + + def test_plugin_without_callbacks(self): + """Test plugin without any callbacks doesn't crash.""" + TTkodePlugin.instances.clear() + + plugin = TTkodePlugin(name="MinimalPlugin") + + # These should not raise exceptions + if plugin.init is not None: + plugin.init() + if plugin.apply is not None: + plugin.apply() + if plugin.run is not None: + plugin.run() + + # If we get here, no exceptions were raised + assert True diff --git a/apps/ttkode/tests/test_plugin.py b/apps/ttkode/tests/test_plugin.py new file mode 100644 index 00000000..fe4423f5 --- /dev/null +++ b/apps/ttkode/tests/test_plugin.py @@ -0,0 +1,212 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +import TermTk as ttk +from ttkode.plugin import ( + TTkodePlugin, + TTkodePluginWidget, + TTkodePluginWidgetActivity, + TTkodePluginWidgetPanel +) + + +class TestTTkodePluginWidget: + """Test the base plugin widget class.""" + + def test_plugin_widget_creation(self): + """Test creating a basic plugin widget.""" + widget = ttk.TTkWidget() + plugin_widget = TTkodePluginWidget(widget=widget) + + assert plugin_widget.widget is widget + + def test_plugin_widget_activity_creation(self): + """Test creating an activity plugin widget.""" + widget = ttk.TTkWidget() + icon = ttk.TTkString("📁") + name = "TestActivity" + + activity_widget = TTkodePluginWidgetActivity( + widget=widget, + activityName=name, + icon=icon + ) + + assert activity_widget.widget is widget + assert activity_widget.activityName == name + assert activity_widget.icon == icon + + def test_plugin_widget_panel_creation(self): + """Test creating a panel plugin widget.""" + widget = ttk.TTkWidget() + panel_name = "TestPanel" + + panel_widget = TTkodePluginWidgetPanel( + widget=widget, + panelName=panel_name + ) + + assert panel_widget.widget is widget + assert panel_widget.panelName == panel_name + + +class TestTTkodePlugin: + """Test the plugin system.""" + + def test_plugin_basic_creation(self): + """Test creating a basic plugin.""" + plugin = TTkodePlugin(name="TestPlugin") + + assert plugin.name == "TestPlugin" + assert plugin.init is None + assert plugin.apply is None + assert plugin.run is None + assert plugin.widgets == [] + assert plugin in TTkodePlugin.instances + + def test_plugin_with_callbacks(self): + """Test plugin with init, apply, and run callbacks.""" + init_called = [] + apply_called = [] + run_called = [] + + def init_cb(): + init_called.append(True) + + def apply_cb(): + apply_called.append(True) + + def run_cb(): + run_called.append(True) + + plugin = TTkodePlugin( + name="CallbackPlugin", + init=init_cb, + apply=apply_cb, + run=run_cb + ) + + # Execute callbacks + if plugin.init: + plugin.init() + if plugin.apply: + plugin.apply() + if plugin.run: + plugin.run() + + assert len(init_called) == 1 + assert len(apply_called) == 1 + assert len(run_called) == 1 + + def test_plugin_with_widgets(self): + """Test plugin with associated widgets.""" + widget1 = ttk.TTkWidget() + widget2 = ttk.TTkWidget() + + plugin_widget1 = TTkodePluginWidget(widget=widget1) + plugin_widget2 = TTkodePluginWidgetActivity( + widget=widget2, + activityName="Activity", + icon=ttk.TTkString("🔧") + ) + + plugin = TTkodePlugin( + name="WidgetPlugin", + widgets=[plugin_widget1, plugin_widget2] + ) + + assert len(plugin.widgets) == 2 + assert plugin.widgets[0] is plugin_widget1 + assert plugin.widgets[1] is plugin_widget2 + + def test_plugin_instances_list(self): + """Test that plugins are registered in instances list.""" + initial_count = len(TTkodePlugin.instances) + + plugin1 = TTkodePlugin(name="Plugin1") + plugin2 = TTkodePlugin(name="Plugin2") + + assert len(TTkodePlugin.instances) == initial_count + 2 + assert plugin1 in TTkodePlugin.instances + assert plugin2 in TTkodePlugin.instances + + def test_plugin_with_all_widget_types(self): + """Test plugin containing different types of widgets.""" + base_widget = ttk.TTkWidget() + activity_widget = ttk.TTkWidget() + panel_widget = ttk.TTkWidget() + + widgets = [ + TTkodePluginWidget(widget=base_widget), + TTkodePluginWidgetActivity( + widget=activity_widget, + activityName="MyActivity", + icon=ttk.TTkString("⚙️") + ), + TTkodePluginWidgetPanel( + widget=panel_widget, + panelName="MyPanel" + ) + ] + + plugin = TTkodePlugin(name="CompletePlugin", widgets=widgets) + + assert len(plugin.widgets) == 3 + assert isinstance(plugin.widgets[0], TTkodePluginWidget) + assert isinstance(plugin.widgets[1], TTkodePluginWidgetActivity) + assert isinstance(plugin.widgets[2], TTkodePluginWidgetPanel) + + def test_plugin_callback_execution_order(self): + """Test that plugin callbacks can be executed in order.""" + execution_order = [] + + def init_cb(): + execution_order.append('init') + + def apply_cb(): + execution_order.append('apply') + + def run_cb(): + execution_order.append('run') + + plugin = TTkodePlugin( + name="OrderPlugin", + init=init_cb, + apply=apply_cb, + run=run_cb + ) + + # Simulate the plugin lifecycle + if plugin.init: + plugin.init() + if plugin.apply: + plugin.apply() + if plugin.run: + plugin.run() + + assert execution_order == ['init', 'apply', 'run'] diff --git a/apps/ttkode/tests/test_proxy.py b/apps/ttkode/tests/test_proxy.py new file mode 100644 index 00000000..cd6c85b6 --- /dev/null +++ b/apps/ttkode/tests/test_proxy.py @@ -0,0 +1,120 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +from ttkode.proxy import TTKodeViewerProxy + + +class TestTTKodeViewerProxy: + """Test the viewer proxy class.""" + + def test_viewer_proxy_creation(self): + """Test creating a viewer proxy with a filename.""" + filename = "/path/to/test/file.py" + proxy = TTKodeViewerProxy(filename) + + assert proxy.fileName() == filename + + def test_viewer_proxy_empty_filename(self): + """Test viewer proxy with an empty filename.""" + proxy = TTKodeViewerProxy("") + assert proxy.fileName() == "" + + def test_viewer_proxy_relative_path(self): + """Test viewer proxy with a relative path.""" + filename = "relative/path/file.txt" + proxy = TTKodeViewerProxy(filename) + assert proxy.fileName() == filename + + def test_viewer_proxy_absolute_path(self): + """Test viewer proxy with an absolute path.""" + filename = "/absolute/path/to/file.py" + proxy = TTKodeViewerProxy(filename) + assert proxy.fileName() == filename + + def test_viewer_proxy_with_special_chars(self): + """Test viewer proxy with special characters in filename.""" + filename = "/path/with spaces/and-dashes/file_name.py" + proxy = TTKodeViewerProxy(filename) + assert proxy.fileName() == filename + + def test_multiple_viewer_proxies(self): + """Test creating multiple viewer proxies.""" + filenames = [ + "/path/file1.py", + "/path/file2.txt", + "/path/file3.md" + ] + + proxies = [TTKodeViewerProxy(fn) for fn in filenames] + + for proxy, filename in zip(proxies, filenames): + assert proxy.fileName() == filename + + def test_viewer_proxy_filename_immutability(self): + """Test that filename is set during initialization and retrieved correctly.""" + original_filename = "/original/path/file.py" + proxy = TTKodeViewerProxy(original_filename) + + # Verify we can retrieve it + assert proxy.fileName() == original_filename + + # Note: The proxy doesn't expose a setter, so filename should remain stable + + +# Note: TTKodeProxy requires a full TTKode instance which depends on the UI framework +# These tests would need a more complex setup with mocked UI components +# For now, we test the simpler TTKodeViewerProxy +# Integration tests for TTKodeProxy should be added when UI mocking infrastructure is available + +class TestTTKodeProxyIntegration: + """Integration tests for TTKodeProxy - these require more setup.""" + + @pytest.mark.skip(reason="Requires full TTKode UI instance setup") + def test_proxy_ttkode_reference(self): + """Test that proxy maintains reference to TTKode instance.""" + # This would require setting up a full TTKode instance + # which depends on the TTk UI framework + pass + + @pytest.mark.skip(reason="Requires full TTKode UI instance setup") + def test_proxy_open_file(self): + """Test opening a file through the proxy.""" + # This would require a full TTKode instance + pass + + @pytest.mark.skip(reason="Requires full TTKode UI instance setup") + def test_proxy_iter_widgets(self): + """Test iterating through widgets via proxy.""" + # This would require a full TTKode instance with widgets + pass + + @pytest.mark.skip(reason="Requires full TTKode UI instance setup") + def test_proxy_close_tab(self): + """Test closing a tab through the proxy.""" + # This would require a full TTKode instance with tabs + pass diff --git a/apps/ttkode/tests/test_search_file.py b/apps/ttkode/tests/test_search_file.py new file mode 100644 index 00000000..5e083987 --- /dev/null +++ b/apps/ttkode/tests/test_search_file.py @@ -0,0 +1,314 @@ +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import sys +import os +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../libs/pyTermTk')) + +from ttkode.app.helpers.search_file import ( + is_text_file, + _load_gitignore_patterns, + _glob_match_patterns, + _custom_walk, + TTKode_SearchFile +) + + +class TestIsTextFile: + """Test text file detection.""" + + def test_text_file_detection(self, tmp_path): + """Test detection of text files.""" + text_file = tmp_path / "test.txt" + text_file.write_text("This is a text file\nwith multiple lines") + + assert is_text_file(str(text_file)) is True + + def test_python_file_detection(self, tmp_path): + """Test Python files are detected as text.""" + py_file = tmp_path / "test.py" + py_file.write_text("print('hello world')") + + assert is_text_file(str(py_file)) is True + + def test_json_file_detection(self, tmp_path): + """Test JSON files are detected as text.""" + json_file = tmp_path / "test.json" + json_file.write_text('{"key": "value"}') + + assert is_text_file(str(json_file)) is True + + def test_binary_file_detection(self, tmp_path): + """Test binary files are detected correctly.""" + binary_file = tmp_path / "test.bin" + binary_file.write_bytes(b'\x00\x01\x02\x03\x04\x05') + + assert is_text_file(str(binary_file)) is False + + def test_empty_file(self, tmp_path): + """Test empty file detection.""" + empty_file = tmp_path / "empty.txt" + empty_file.write_text("") + + # Empty files might be considered text depending on implementation + result = is_text_file(str(empty_file)) + assert isinstance(result, bool) + + +class TestLoadGitignorePatterns: + """Test loading .gitignore patterns.""" + + def test_load_existing_gitignore(self, tmp_path): + """Test loading an existing .gitignore file.""" + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.pyc\n__pycache__/\n.env\n") + + patterns = _load_gitignore_patterns(str(gitignore)) + + assert "*.pyc" in patterns + assert "__pycache__/" in patterns + assert ".env" in patterns + + def test_load_nonexistent_gitignore(self, tmp_path): + """Test loading a non-existent .gitignore file.""" + gitignore_path = str(tmp_path / ".gitignore") + patterns = _load_gitignore_patterns(gitignore_path) + + assert patterns == [] + + def test_empty_gitignore(self, tmp_path): + """Test loading an empty .gitignore file.""" + gitignore = tmp_path / ".gitignore" + gitignore.write_text("") + + patterns = _load_gitignore_patterns(str(gitignore)) + assert patterns == [] + + def test_gitignore_with_comments(self, tmp_path): + """Test .gitignore with comments.""" + gitignore = tmp_path / ".gitignore" + gitignore.write_text("# This is a comment\n*.pyc\n# Another comment\n__pycache__/") + + patterns = _load_gitignore_patterns(str(gitignore)) + + # Comments should be included as-is (filtering happens in matching) + assert len(patterns) == 4 + + +class TestGlobMatchPatterns: + """Test glob pattern matching.""" + + def test_simple_pattern_match(self): + """Test simple pattern matching.""" + assert _glob_match_patterns("test.pyc", ["*.pyc"]) is True + assert _glob_match_patterns("test.py", ["*.pyc"]) is False + + def test_directory_pattern_match(self): + """Test directory pattern matching.""" + assert _glob_match_patterns("./__pycache__/module.pyc", ["__pycache__"]) is True + assert _glob_match_patterns("./src/__pycache__/", ["__pycache__"]) is True + + def test_multiple_patterns(self): + """Test matching against multiple patterns.""" + patterns = ["*.pyc", "*.pyo", "__pycache__"] + + assert _glob_match_patterns("test.pyc", patterns) is True + assert _glob_match_patterns("test.pyo", patterns) is True + assert _glob_match_patterns("./__pycache__/", patterns) is True + assert _glob_match_patterns("test.py", patterns) is False + + def test_empty_patterns(self): + """Test with empty pattern list.""" + assert _glob_match_patterns("test.py", []) is False + + def test_current_directory(self): + """Test matching with current directory.""" + result = _glob_match_patterns(".", ["*.pyc"]) + assert isinstance(result, bool) + + def test_relative_path_matching(self): + """Test matching relative paths.""" + assert _glob_match_patterns("./src/module.pyc", ["*.pyc"]) is True + assert _glob_match_patterns("src/module.py", ["*.pyc"]) is False + + +class TestCustomWalk: + """Test custom directory walking.""" + + def test_walk_simple_directory(self, tmp_path): + """Test walking a simple directory structure.""" + # Create test structure + (tmp_path / "file1.py").write_text("print('file1')") + (tmp_path / "file2.txt").write_text("text") + + results = list(_custom_walk(str(tmp_path))) + + assert len(results) == 2 + filenames = [entry[1] for entry in results] + assert "file1.py" in filenames + assert "file2.txt" in filenames + + def test_walk_nested_directories(self, tmp_path): + """Test walking nested directories.""" + # Create nested structure + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "root.py").write_text("root") + (subdir / "nested.py").write_text("nested") + + results = list(_custom_walk(str(tmp_path))) + + assert len(results) == 2 + filenames = [entry[1] for entry in results] + assert "root.py" in filenames + assert "nested.py" in filenames + + def test_walk_with_exclude_patterns(self, tmp_path): + """Test walking with exclusion patterns.""" + # Create files + (tmp_path / "include.py").write_text("include") + (tmp_path / "exclude.pyc").write_text("exclude") + + results = list(_custom_walk(str(tmp_path), exclude_patterns=["*.pyc"])) + + filenames = [entry[1] for entry in results] + assert "include.py" in filenames + assert "exclude.pyc" not in filenames + + def test_walk_with_include_patterns(self, tmp_path): + """Test walking with inclusion patterns.""" + # Create files + (tmp_path / "test.py").write_text("python") + (tmp_path / "test.txt").write_text("text") + + results = list(_custom_walk(str(tmp_path), include_patterns=["*.py"])) + + filenames = [entry[1] for entry in results] + assert "test.py" in filenames + assert "test.txt" not in filenames + + def test_walk_ignores_git_directory(self, tmp_path): + """Test that .git directories are ignored.""" + # Create .git directory + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("git config") + (tmp_path / "normal.py").write_text("normal file") + + results = list(_custom_walk(str(tmp_path))) + + filenames = [entry[1] for entry in results] + assert "normal.py" in filenames + assert "config" not in filenames + + def test_walk_respects_gitignore(self, tmp_path): + """Test that .gitignore patterns are respected.""" + # Create .gitignore + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.pyc\n__pycache__/") + + # Create files + (tmp_path / "normal.py").write_text("include") + (tmp_path / "compiled.pyc").write_text("exclude") + + results = list(_custom_walk(str(tmp_path))) + + filenames = [entry[1] for entry in results] + assert "normal.py" in filenames + assert ".gitignore" in filenames + assert "compiled.pyc" not in filenames + + +class TestTTKodeSearchFile: + """Test the file search functionality.""" + + def test_search_by_pattern(self, tmp_path): + """Test searching files by pattern.""" + # Create test files + pass + (tmp_path / "test_Eugenio_ABC_file.py").write_text("test") + (tmp_path / "other.txt").write_text("other") + (tmp_path / "test_Eugenio_ABC_data.json").write_text("{}") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "test_Eugenio_ABC")) + + assert len(results) == 2 + result_names = [r.name for r in results] + assert "test_Eugenio_ABC_file.py" in result_names + assert "test_Eugenio_ABC_data.json" in result_names + assert "other.txt" not in result_names + + def test_search_empty_pattern(self, tmp_path): + """Test searching with empty pattern matches all files.""" + (tmp_path / "file1.py").write_text("1") + (tmp_path / "file2.txt").write_text("2") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "")) + + # Empty pattern should match all files + assert len(results) >= 2 + + def test_search_no_matches(self, tmp_path): + """Test searching with pattern that matches nothing.""" + (tmp_path / "file1.py").write_text("1") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "nonexistent_pattern_xyz")) + + assert len(results) == 0 + + def test_search_nested_directories(self, tmp_path): + """Test searching in nested directories.""" + # Create nested structure + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "root_test.py").write_text("root") + (subdir / "nested_test.py").write_text("nested") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "test")) + + assert len(results) == 2 + result_names = [r.name for r in results] + assert "root_test.py" in result_names + assert "nested_test.py" in result_names + + def test_search_returns_path_objects(self, tmp_path): + """Test that search returns Path objects.""" + (tmp_path / "test.py").write_text("test") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "test")) + + assert len(results) > 0 + for result in results: + assert isinstance(result, Path) + + def test_search_case_sensitivity(self, tmp_path): + """Test search pattern case sensitivity.""" + (tmp_path / "TestFile.py").write_text("test") + (tmp_path / "testfile.txt").write_text("test") + + results = list(TTKode_SearchFile.getFilesFromPattern(tmp_path, "test")) + + # Should match both (case-insensitive glob matching) + assert len(results) >= 2 diff --git a/apps/ttkode/ttkode/app/ttkode.py b/apps/ttkode/ttkode/app/ttkode.py index 972596e4..b96c9227 100644 --- a/apps/ttkode/ttkode/app/ttkode.py +++ b/apps/ttkode/ttkode/app/ttkode.py @@ -35,6 +35,7 @@ from TermTk.TTkWidgets.tabwidget import _TTkNewTabWidgetDragData from .about import About from .command_palette.command_palette import TTKode_CommandPalette from .activitybar import TTKodeActivityBar +from .ttkode_terminal import TTkode_Terminal class TTKodeWidget(): def closeRequested(self, tab:ttk.TTkTabWidget, num:int): @@ -265,6 +266,8 @@ class TTKode(ttk.TTkGridLayout): helpMenu = appMenuBar.addMenu("&Help", alignment=ttk.TTkK.RIGHT_ALIGN) helpMenu.addMenu("About ...").menuButtonClicked.connect(_showAbout) helpMenu.addMenu("About ttk").menuButtonClicked.connect(_showAboutTTk) + helpMenu.addSpacer() + helpMenu.addMenu("&KeypressView").menuButtonClicked.connect(self._keypressview) fileTree = ttk.TTkFileTree(path='.', dragDropMode=ttk.TTkK.DragDropMode.AllowDrag, selectionMode=ttk.TTkK.SelectionMode.MultiSelection) self._activityBar = TTKodeActivityBar() @@ -285,10 +288,7 @@ class TTKode(ttk.TTkGridLayout): menuBar=bottomMenuBar) _logViewer=ttk.TTkLogViewer() - _terminal=ttk.TTkTerminal(visible=False) - - _th = ttk.TTkTerminalHelper(term=_terminal) - _th.runShell() + _terminal=TTkode_Terminal(visible=False) self._panel.addWidget( position=_Panel.Position.BOTTOM, @@ -479,3 +479,13 @@ class TTKode(ttk.TTkGridLayout): newEvt.setData(newData) return newEvt return evt + + @ttk.pyTTkSlot() + def _keypressview(self): + win = ttk.TTkWindow( + title="Mr Keypress 🔑🐁", + size=(70,7), + layout=(_l:=ttk.TTkGridLayout()), + flags=ttk.TTkK.WindowFlag.WindowMaximizeButtonHint|ttk.TTkK.WindowFlag.WindowCloseButtonHint) + _l.addWidget(ttk.TTkKeyPressView(maxHeight=3)) + ttk.TTkHelper.overlay(None, win, 2, 2, toolWindow=True) \ No newline at end of file diff --git a/apps/ttkode/ttkode/app/ttkode_terminal.py b/apps/ttkode/ttkode/app/ttkode_terminal.py new file mode 100644 index 00000000..430aebf3 --- /dev/null +++ b/apps/ttkode/ttkode/app/ttkode_terminal.py @@ -0,0 +1,14 @@ +import TermTk as ttk + +class TTkode_Terminal(ttk.TTkTerminal): + __slots__ = ('_isRunning') + def __init__(self, **kwargs): + self._isRunning = False + super().__init__(**kwargs) + + def show(self): + if not self._isRunning: + self._isRunning = True + _th = ttk.TTkTerminalHelper(term=self) + _th.runShell() + return super().show() \ No newline at end of file