Skip to content

Commit 9f8c59c

Browse files
test: add unit tests for cli and core functions
1 parent f3c9e18 commit 9f8c59c

7 files changed

Lines changed: 454 additions & 2 deletions

File tree

.annotator.jsonc

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
// List of templates to apply (keeping default as is always recommended)
3+
// Add more templates in the list
4+
// existing templates can be seen here `https://github.com/assignment-sets/annotator-cli`
5+
"templates": ["default", "python3"],
6+
7+
// Behavior settings
8+
"settings": {
9+
"max_recursive_depth": 10, // How deep to recurse into folders
10+
"max_num_of_files": 1000, // Maximum files to process
11+
"max_file_size_kb": 512 // Skip files larger than this
12+
},
13+
14+
// Override comment styles for specific extensions
15+
// Example: ".kt": "//", ".scala": "//"
16+
"comment_styles": {},
17+
18+
// Additional file extensions to exclude (beyond defaults)
19+
// Example: [".txt", ".log"]
20+
// keeping existing items is prefered
21+
"exclude_extensions": [".log", ".cache"],
22+
23+
// Additional directories to exclude (supports nested paths)
24+
// Example: ["temp", "cache", "src/generated/proto"]
25+
// keeping existing items is prefered
26+
"exclude_dirs": ["node_modules", ".venv", "__pycache__", "dist", "build", "target", "bin", ".git"],
27+
28+
// Additional specific file `names` to exclude [no support for nested paths]
29+
// Example: [".env.local", "config.json"]
30+
// keeping existing items is prefered
31+
"exclude_files": [".env", ".annotator.jsonc", ".gitignore"]
32+
}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ wheels/
77
*.egg-info
88
*.egg-info/
99
build/
10+
.pytest_cache/
11+
.coverage
1012

1113
# Virtual environments
1214
.venv

annotator/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
# __init__.py

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,7 @@ annotator = ["templates/*.json"]
2626
[dependency-groups]
2727
dev = [
2828
"pre-commit>=4.5.1",
29+
"pytest>=9.0.2",
30+
"pytest-cov>=7.0.0",
31+
"ruff>=0.15.2",
2932
]

tests/test_cli_func.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from annotator.cli import merge_configs, load_json_with_comments
2+
3+
4+
def test_merge_configs():
5+
base = {
6+
"settings": {"depth": 10, "files": 100},
7+
"exclude_dirs": ["node_modules", "dist"],
8+
"comment_styles": {".py": "#"},
9+
}
10+
overlay = {
11+
"name": "template-name-to-ignore",
12+
"settings": {"files": 500, "new_setting": True},
13+
"exclude_dirs": ["dist", ".venv"],
14+
"comment_styles": {".js": "//"},
15+
}
16+
17+
result = merge_configs(base, overlay)
18+
19+
# 1. 'name' must be skipped
20+
assert "name" not in result
21+
22+
# 2. Dictionaries must deep merge
23+
assert result["settings"]["depth"] == 10
24+
assert result["settings"]["files"] == 500 # Overwritten
25+
assert result["settings"]["new_setting"] is True
26+
27+
# 3. Lists must combine uniquely
28+
assert set(result["exclude_dirs"]) == {"node_modules", "dist", ".venv"}
29+
30+
# 4. Standard keys just merge
31+
assert result["comment_styles"] == {".py": "#", ".js": "//"}
32+
33+
34+
def test_merge_configs_empty_base():
35+
overlay = {"a": 1, "name": "skip"}
36+
result = merge_configs({}, overlay)
37+
38+
# NOTE: Because of the early return `if not base: return overlay.copy()` in cli.py,
39+
# the 'name' key is NOT stripped when the base dictionary is empty.
40+
# This assertion is updated to reflect the actual function behavior.
41+
assert result == {"a": 1, "name": "skip"}
42+
43+
44+
def test_load_json_with_comments_success(tmp_path):
45+
config_file = tmp_path / "test.jsonc"
46+
47+
# Testing 4 things: standard comments, comments inside strings, trailing commas in lists, trailing commas in dicts
48+
json_content = """{
49+
// This is a standard comment
50+
"url": "https://github.com/foo // this should NOT be stripped",
51+
"numbers": [
52+
1,
53+
2, // trailing comma here
54+
],
55+
"nested": {
56+
"val": true, // trailing comma here
57+
}
58+
}"""
59+
60+
config_file.write_text(json_content)
61+
62+
result = load_json_with_comments(str(config_file))
63+
64+
assert result.get("url") == "https://github.com/foo // this should NOT be stripped"
65+
assert result.get("numbers") == [1, 2]
66+
assert result.get("nested") == {"val": True}
67+
68+
69+
def test_load_json_with_comments_file_not_found():
70+
# Should safely return an empty dict without crashing
71+
result = load_json_with_comments("does_not_exist.jsonc")
72+
assert result == {}
73+
74+
75+
def test_load_json_with_comments_invalid_json(tmp_path):
76+
bad_file = tmp_path / "bad.jsonc"
77+
bad_file.write_text("{ this is completely broken }")
78+
79+
# Should catch the JSONDecodeError and return empty dict
80+
result = load_json_with_comments(str(bad_file))
81+
assert result == {}

tests/test_core.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
from annotator.core import (
2+
get_extension,
3+
should_skip_extensionless_file,
4+
is_too_deep,
5+
is_too_large,
6+
is_in_excluded_directory,
7+
get_prefix,
8+
get_annotation_text,
9+
apply_annotation,
10+
remove_annotation,
11+
SIGNATURE,
12+
)
13+
14+
# --- Pure Function Tests ---
15+
16+
17+
def test_get_extension():
18+
assert get_extension("script.py") == ".py"
19+
assert get_extension("archive.tar.gz") == ".gz"
20+
assert get_extension("Makefile") == ""
21+
assert (
22+
get_extension(".gitignore") == ""
23+
) # os.path.splitext treats this as root, empty ext
24+
25+
26+
def test_is_too_deep():
27+
root = "/app"
28+
assert is_too_deep("/app/src/main", root, max_depth=1) is True
29+
assert is_too_deep("/app/src/main", root, max_depth=2) is False
30+
assert is_too_deep("/app", root, max_depth=0) is False
31+
32+
33+
def test_is_in_excluded_directory():
34+
exclude_dirs = {"node_modules", "build", ".git"}
35+
36+
# Direct match
37+
assert is_in_excluded_directory("node_modules/react/index.js", exclude_dirs) is True
38+
assert is_in_excluded_directory("build/output.css", exclude_dirs) is True
39+
40+
# Nested match
41+
assert (
42+
is_in_excluded_directory("src/components/node_modules/test.js", exclude_dirs)
43+
is False
44+
) # Only matches prefix or exact folder
45+
exclude_nested = {"src/generated"}
46+
assert is_in_excluded_directory("src/generated/api.ts", exclude_nested) is True
47+
48+
# Safe path
49+
assert is_in_excluded_directory("src/main.py", exclude_dirs) is False
50+
51+
52+
def test_get_prefix():
53+
config = {
54+
"comment_styles": {".py": "#", ".js": "//", "Dockerfile": "#", ".html": "<!--"}
55+
}
56+
assert get_prefix("script.py", config) == "#"
57+
assert get_prefix("app.js", config) == "//"
58+
assert get_prefix("Dockerfile", config) == "#" # Matches filename
59+
assert get_prefix("unknown.xyz", config) == "#" # Fallback
60+
61+
62+
def test_get_annotation_text():
63+
config = {"comment_styles": {".py": "#", ".html": "<!--"}}
64+
65+
text_py = get_annotation_text("src/main.py", "main.py", config)
66+
assert text_py == f"# src/main.py {SIGNATURE}\n"
67+
68+
text_html = get_annotation_text("public/index.html", "index.html", config)
69+
assert text_html == f"<!-- public/index.html {SIGNATURE} -->\n"
70+
71+
72+
# --- File I/O Tests (Using pytest tmp_path fixture) ---
73+
74+
75+
def test_should_skip_extensionless_file(tmp_path):
76+
# Test Shebang
77+
bash_file = tmp_path / "script"
78+
bash_file.write_text("#!/bin/bash\necho 'hello'")
79+
assert should_skip_extensionless_file(str(bash_file)) is True
80+
81+
# Test XML
82+
xml_file = tmp_path / "config"
83+
xml_file.write_text('<?xml version="1.0"?>\n<root></root>')
84+
assert should_skip_extensionless_file(str(xml_file)) is True
85+
86+
# Normal text
87+
txt_file = tmp_path / "notes"
88+
txt_file.write_text("Just some regular text\nNothing special.")
89+
assert should_skip_extensionless_file(str(txt_file)) is False
90+
91+
92+
def test_is_too_large(tmp_path):
93+
big_file = tmp_path / "large.bin"
94+
# Create a 2KB file (2048 bytes)
95+
big_file.write_bytes(b"0" * 2048)
96+
97+
assert is_too_large(str(big_file), max_kb=1) is True
98+
assert is_too_large(str(big_file), max_kb=3) is False
99+
100+
101+
def test_apply_and_remove_annotation(tmp_path):
102+
target_file = tmp_path / "test_script.py"
103+
target_file.write_text("print('hello world')\n")
104+
105+
filepath = str(target_file)
106+
rel_path = "test_script.py"
107+
config = {"comment_styles": {".py": "#"}}
108+
109+
# 1. Apply annotation
110+
success = apply_annotation(filepath, rel_path, config)
111+
assert success is True
112+
content = target_file.read_text()
113+
assert content.startswith(f"# {rel_path} {SIGNATURE}\n")
114+
assert "print('hello world')" in content
115+
116+
# 2. Prevent double annotation
117+
success_duplicate = apply_annotation(filepath, rel_path, config)
118+
assert success_duplicate is False # Should return False because signature exists
119+
120+
# 3. Remove annotation
121+
success_remove = remove_annotation(filepath, rel_path)
122+
assert success_remove is True
123+
clean_content = target_file.read_text()
124+
assert clean_content == "print('hello world')\n" # Back to original
125+
126+
# 4. Remove when no annotation exists
127+
success_remove_again = remove_annotation(filepath, rel_path)
128+
assert success_remove_again is False
129+
130+
131+
class MockGitIgnore:
132+
def match_file(self, path):
133+
return path == "ignored_file.txt"
134+
135+
136+
def test_is_git_ignored():
137+
spec = MockGitIgnore()
138+
139+
from annotator.core import is_git_ignored
140+
141+
assert is_git_ignored("ignored_file.txt", spec) is True
142+
assert is_git_ignored("tracked_file.txt", spec) is False
143+
assert is_git_ignored("file.txt", None) is False

0 commit comments

Comments
 (0)