Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

151 changes: 151 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
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")
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")
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