From c0aa352f9335a2032f7923f2bc394172fc6aad0b Mon Sep 17 00:00:00 2001 From: Colin Brown Date: Fri, 16 Jan 2026 13:08:09 +0000 Subject: [PATCH 1/2] test: Add unit tests for configuration commands and validation --- tests/test_cli.py | 157 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 151 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index ad390b8..b82b5a1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -283,3 +283,160 @@ def test_panel_shows_marker_breakdown(self, capsys): assert ": 2" in captured.out assert ": 1" in captured.out + +class TestConfigCommands: + """Test cases for config-related commands.""" + + def test_config_show_command_with_config_file(self, capsys): + """Test 'tickle config show' displays configuration from file.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "tickle.toml" + config_file.write_text( + '[tickle]\n' + 'markers = ["TODO", "FIXME"]\n' + 'ignore = ["node_modules", "dist"]\n' + 'format = "json"\n' + ) + + with mock.patch("sys.argv", ["tickle", "config", "show", "--config", str(config_file)]): + main() + captured = capsys.readouterr() + + assert "TODO" in captured.out + assert "FIXME" in captured.out + assert "node_modules" in captured.out + assert "json" in captured.out + + def test_config_show_command_no_config(self, capsys): + """Test 'tickle config show' with no config file.""" + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch("sys.argv", ["tickle", "config", "show", tmpdir]): + main() + captured = capsys.readouterr() + + # Should show defaults + assert "markers" in captured.out.lower() or "todo" in captured.out.lower() + + def test_config_command_without_subcommand_shows_help(self, capsys): + """Test 'tickle config' without subcommand shows help.""" + with mock.patch("sys.argv", ["tickle", "config"]): + main() + captured = capsys.readouterr() + + # Should show help text + assert "show" in captured.out.lower() or "usage" in captured.out.lower() + + def test_init_command_success(self, capsys): + """Test 'tickle init' creates config file.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = Path.cwd() + try: + import os + os.chdir(tmpdir) + + with mock.patch("sys.argv", ["tickle", "init"]): + main() + captured = capsys.readouterr() + + assert Path("tickle.toml").exists() + assert "Created" in captured.out + finally: + os.chdir(original_cwd) + + def test_init_command_file_exists_error(self, capsys): + """Test 'tickle init' errors when file already exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = Path.cwd() + try: + import os + os.chdir(tmpdir) + + # Create existing config file + Path("tickle.toml").write_text("[tickle]\n") + + with mock.patch("sys.argv", ["tickle", "init"]): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + captured = capsys.readouterr() + assert "already exists" in captured.out + finally: + os.chdir(original_cwd) + + def test_init_command_handles_write_error(self, capsys): + """Test 'tickle init' handles permission errors gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = Path.cwd() + try: + import os + os.chdir(tmpdir) + + with mock.patch("pathlib.Path.write_text", side_effect=PermissionError("Access denied")): + with mock.patch("sys.argv", ["tickle", "init"]): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + captured = capsys.readouterr() + assert "Error" in captured.out + finally: + os.chdir(original_cwd) + + def test_scan_with_verbose_flag(self, capsys): + """Test --verbose flag shows config file being used.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + (tmpdir_path / "test.py").write_text("# TODO: Test task\n") + + # Create a config file + config_file = tmpdir_path / "tickle.toml" + config_file.write_text('[tickle]\nmarkers = ["TODO"]\n') + + with mock.patch("sys.argv", ["tickle", str(tmpdir), "--verbose"]): + main() + captured = capsys.readouterr() + + # Should show config path in stderr + output = captured.out + captured.err + assert "config" in output.lower() or "tickle.toml" in output + + def test_scan_with_no_config_flag(self, capsys): + """Test --no-config flag ignores config files.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + (tmpdir_path / "test.py").write_text("# TODO: Test task\n# FIXME: Another\n") + + # Create a config that would filter out TODO + config_file = tmpdir_path / "tickle.toml" + config_file.write_text('[tickle]\nmarkers = ["FIXME"]\n') + + with mock.patch("sys.argv", ["tickle", str(tmpdir), "--no-config"]): + main() + captured = capsys.readouterr() + + # Should still find TODO because config is ignored + assert "TODO" in captured.out + + +class TestPlatformSpecific: + """Test platform-specific behavior.""" + + def test_windows_utf8_encoding(self): + """Test Windows UTF-8 stdout reconfiguration.""" + with mock.patch("sys.platform", "win32"): + with mock.patch("sys.stdout.reconfigure") as mock_reconfig: + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch("sys.argv", ["tickle", tmpdir]): + main() + mock_reconfig.assert_called_once_with(encoding="utf-8") + + def test_non_windows_no_reconfigure(self): + """Test non-Windows platforms don't reconfigure encoding.""" + with mock.patch("sys.platform", "linux"): + with mock.patch("sys.stdout.reconfigure") as mock_reconfig: + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch("sys.argv", ["tickle", tmpdir]): + main() + mock_reconfig.assert_not_called() + diff --git a/tests/test_config.py b/tests/test_config.py index 9c09967..2af6aa6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -553,3 +553,154 @@ def test_cli_override_project_config(self): assert merged["markers"] == ["BUG"] assert merged["ignore_patterns"] == ["build"] assert merged["format"] == "markdown" + + +class TestBooleanValidation: + """Test cases for boolean field validation.""" + + def test_load_config_invalid_reverse_type(self): + """Test warning for invalid reverse type.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tickle.toml" + config_path.write_text('[tickle]\nreverse = "true"\n') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = load_config(config_path) + assert any("reverse" in str(warning.message).lower() for warning in w) + assert config.reverse is None # Invalid value results in None + + def test_load_config_invalid_include_hidden_type(self): + """Test warning for invalid include_hidden type.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tickle.toml" + config_path.write_text('[tickle]\ninclude_hidden = 1\n') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = load_config(config_path) + assert any("include_hidden" in str(warning.message).lower() for warning in w) + assert config.include_hidden is None # Invalid value results in None + + def test_load_config_invalid_git_verbose_type(self): + """Test warning for invalid git_verbose type.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tickle.toml" + config_path.write_text('[tickle]\ngit_verbose = "yes"\n') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = load_config(config_path) + assert any("git_verbose" in str(warning.message).lower() for warning in w) + + def test_load_config_invalid_tree_collapse_type(self): + """Test warning for invalid tree_collapse type.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tickle.toml" + config_path.write_text('[tickle]\ntree_collapse = []\n') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = load_config(config_path) + assert any("tree_collapse" in str(warning.message).lower() for warning in w) + + def test_load_config_invalid_git_blame_type(self): + """Test warning for invalid git_blame type.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tickle.toml" + config_path.write_text('[tickle]\ngit_blame = "false"\n') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = load_config(config_path) + assert any("git_blame" in str(warning.message).lower() for warning in w) + + +class TestMergeEdgeCases: + """Test edge cases in config merging.""" + + def test_merge_config_empty_ignore_string(self): + """Test merge handles empty ignore string.""" + import argparse + config = TickleConfig() + args = argparse.Namespace( + markers="TODO,FIXME,BUG,NOTE,HACK,CHECKBOX", + ignore="", + format="tree", + sort="file", + reverse=False, + include_hidden=False, + no_blame=False, + git_verbose=False, + tree_collapse=False, + path="." + ) + + merged = merge_config_with_args(config, args) + assert merged["ignore_patterns"] == [] + + def test_merge_config_git_blame_combinations(self): + """Test various git_blame and no_blame combinations.""" + import argparse + + # Test: config.git_blame=True, args.no_blame=False + config = TickleConfig(git_blame=True) + args = argparse.Namespace( + markers="TODO", + ignore="", + format="tree", + sort="file", + reverse=False, + include_hidden=False, + no_blame=False, + git_verbose=False, + tree_collapse=False, + path="." + ) + merged = merge_config_with_args(config, args) + assert merged["enable_git_blame"] is True + + # Test: config.git_blame=False, args.no_blame=False (config wins) + config = TickleConfig(git_blame=False) + merged = merge_config_with_args(config, args) + assert merged["enable_git_blame"] is False + + # Test: config.git_blame=True, args.no_blame=True (CLI wins) + args.no_blame = True + config = TickleConfig(git_blame=True) + merged = merge_config_with_args(config, args) + assert merged["enable_git_blame"] is False + + def test_merge_config_whitespace_in_ignore(self): + """Test merge handles whitespace in ignore patterns.""" + import argparse + config = TickleConfig() + args = argparse.Namespace( + markers="TODO", + ignore=" node_modules , dist , build ", + format="tree", + sort="file", + reverse=False, + include_hidden=False, + no_blame=False, + git_verbose=False, + tree_collapse=False, + path="." + ) + + merged = merge_config_with_args(config, args) + assert "node_modules" in merged["ignore_patterns"] + assert "dist" in merged["ignore_patterns"] + assert "build" in merged["ignore_patterns"] + + +class TestWindowsEdgeCases: + """Test Windows-specific edge cases.""" + + def test_windows_without_appdata(self): + """Test Windows config path when APPDATA is not set.""" + with mock.patch("sys.platform", "win32"): + with mock.patch.dict(os.environ, {}, clear=True): + # Returns None when APPDATA not available on Windows + path = get_user_config_path() + assert path is None From 9bd0413ded75d40d3995c14eb8a34089ca3cd11c Mon Sep 17 00:00:00 2001 From: Colin Brown Date: Fri, 16 Jan 2026 13:17:40 +0000 Subject: [PATCH 2/2] test: Remove unused variable assignment in load_config warnings tests --- tests/test_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 2af6aa6..091769b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -590,7 +590,7 @@ def test_load_config_invalid_git_verbose_type(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - config = load_config(config_path) + load_config(config_path) assert any("git_verbose" in str(warning.message).lower() for warning in w) def test_load_config_invalid_tree_collapse_type(self): @@ -601,7 +601,7 @@ def test_load_config_invalid_tree_collapse_type(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - config = load_config(config_path) + load_config(config_path) assert any("tree_collapse" in str(warning.message).lower() for warning in w) def test_load_config_invalid_git_blame_type(self): @@ -612,7 +612,7 @@ def test_load_config_invalid_git_blame_type(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - config = load_config(config_path) + load_config(config_path) assert any("git_blame" in str(warning.message).lower() for warning in w)