Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
- Remove deprecated 'query' command (replaced by 'db-query') - Remove deprecated 'schema' command (replaced by 'db-schema') - Remove 4 obsolete tests that tested deprecated functionality - Update all remaining tests to use new db-prefixed command names - CLI now has clean, consistent command structure with proper prefixes - All 478 tests passing after cleanup This completes the CLI consistency convention implementation where all subsystem commands follow the "*-stats" pattern and use proper prefixes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""
|
|
Tests for Issue #15: AST Query and Analysis CLI.
|
|
|
|
TDD approach: These tests define the exact requirements for AST introspection commands.
|
|
All tests should initially FAIL (RED) and drive the implementation (GREEN).
|
|
|
|
Commands to implement:
|
|
- `markitect ast-show <file>` - Display AST structure for file
|
|
- `markitect ast-query <file> <jsonpath>` - Query AST using JSONPath
|
|
- `markitect ast-stats <file>` - Show AST statistics (headings, links, etc.)
|
|
|
|
Core USP: "Zero-Parsing Content Access" - Leverage cached ASTs for performance
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from markitect.cli import cli
|
|
from markitect.ast_cache import ASTCache
|
|
|
|
|
|
class TestASTCommands:
|
|
"""TDD test suite defining AST introspection command requirements."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test environment."""
|
|
self.runner = CliRunner()
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.cache_dir = Path(self.temp_dir) / ".ast_cache"
|
|
|
|
# Create test markdown file with rich content for AST analysis
|
|
self.test_file = Path(self.temp_dir) / "test.md"
|
|
self.test_file.write_text("""---
|
|
title: Test Document
|
|
author: Test Author
|
|
tags: [test, markdown]
|
|
---
|
|
|
|
# Main Heading
|
|
|
|
This is a paragraph with **bold** and *italic* text.
|
|
|
|
## Second Heading
|
|
|
|
- List item 1
|
|
- List item 2 with [link](https://example.com)
|
|
|
|
### Third Heading
|
|
|
|
1. Numbered item 1
|
|
2. Numbered item 2
|
|
|
|
[Another link](https://test.com)
|
|
|
|
> This is a blockquote
|
|
|
|
`inline code` and code block:
|
|
|
|
```python
|
|
print("hello world")
|
|
```
|
|
""")
|
|
|
|
def teardown_method(self):
|
|
"""Clean up after each test."""
|
|
import shutil
|
|
if Path(self.temp_dir).exists():
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
# ===== ast-show command tests =====
|
|
|
|
def test_ast_show_command_exists(self):
|
|
"""RED: ast-show command should exist and be callable."""
|
|
result = self.runner.invoke(cli, ['ast-show', str(self.test_file)])
|
|
|
|
# Should NOT be "No such command" - command must exist
|
|
assert "No such command" not in result.output
|
|
# Command exists and runs (may fail for other reasons initially)
|
|
assert result.exit_code in [0, 1, 2]
|
|
|
|
def test_ast_show_requires_file_argument(self):
|
|
"""RED: ast-show should require a file argument."""
|
|
result = self.runner.invoke(cli, ['ast-show'])
|
|
|
|
assert result.exit_code != 0
|
|
assert any(phrase in result.output for phrase in ["Missing argument", "Usage:", "FILE"])
|
|
|
|
def test_ast_show_displays_ast_structure(self):
|
|
"""ast-show should display the AST structure of the markdown file."""
|
|
result = self.runner.invoke(cli, ['ast-show', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should show AST structure with tokens
|
|
assert any(token_type in result.output for token_type in [
|
|
"heading_open", "paragraph_open", "strong_open", "list_item_open"
|
|
])
|
|
# Should show hierarchical structure (indentation or level info)
|
|
assert (" [" in result.output or "nesting" in result.output or "level" in result.output)
|
|
|
|
def test_ast_show_handles_nonexistent_file(self):
|
|
"""RED: ast-show should handle non-existent files gracefully."""
|
|
nonexistent_file = Path(self.temp_dir) / "nonexistent.md"
|
|
|
|
result = self.runner.invoke(cli, ['ast-show', str(nonexistent_file)])
|
|
|
|
# Should handle gracefully with clear error message
|
|
assert result.exit_code != 0
|
|
assert any(phrase in result.output.lower() for phrase in [
|
|
"not found", "does not exist", "file not found"
|
|
])
|
|
|
|
def test_ast_show_command_uses_cached_data_for_improved_performance(self):
|
|
"""ast-show should leverage existing AST cache for performance."""
|
|
# Pre-populate cache
|
|
cache = ASTCache(self.cache_dir)
|
|
cache.cache_file(self.test_file)
|
|
|
|
# Mock cache loading to verify it's used
|
|
with patch.object(ASTCache, 'load_cached_ast') as mock_get:
|
|
mock_get.return_value = [{"type": "heading_open", "tag": "h1"}]
|
|
|
|
result = self.runner.invoke(cli, ['ast-show', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should have called cache instead of parsing
|
|
mock_get.assert_called_once()
|
|
|
|
def test_ast_show_provides_readable_output_format(self):
|
|
"""ast-show should provide human-readable AST display."""
|
|
result = self.runner.invoke(cli, ['ast-show', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should be formatted for readability, not raw JSON dump
|
|
assert len(result.output.strip().split('\n')) > 5 # Multi-line output
|
|
# Should contain structural information
|
|
assert any(content in result.output for content in [
|
|
"Main Heading", "bold", "italic", "List item"
|
|
])
|
|
|
|
# ===== ast-query command tests =====
|
|
|
|
def test_ast_query_command_exists(self):
|
|
"""RED: ast-query command should exist and require arguments."""
|
|
result = self.runner.invoke(cli, ['ast-query'])
|
|
|
|
assert "No such command" not in result.output
|
|
# Should fail due to missing arguments, not unknown command
|
|
if result.exit_code != 0:
|
|
assert any(phrase in result.output for phrase in ["Missing argument", "Usage:"])
|
|
|
|
def test_ast_query_requires_file_and_jsonpath_arguments(self):
|
|
"""RED: ast-query should require both file and jsonpath arguments."""
|
|
# Test missing both arguments
|
|
result = self.runner.invoke(cli, ['ast-query'])
|
|
assert result.exit_code != 0
|
|
|
|
# Test missing jsonpath argument
|
|
result = self.runner.invoke(cli, ['ast-query', str(self.test_file)])
|
|
assert result.exit_code != 0
|
|
|
|
def test_ast_query_executes_jsonpath_queries(self):
|
|
"""ast-query should execute JSONPath queries on AST structure."""
|
|
# Query for first 3 tokens
|
|
result = self.runner.invoke(cli, [
|
|
'ast-query', str(self.test_file), '$[:3]'
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
# Should return matching AST nodes
|
|
assert len(result.output.strip()) > 0
|
|
# Should show query results, not full AST
|
|
assert "type" in result.output
|
|
# Should show query results, not full AST
|
|
assert len(result.output.strip()) > 0
|
|
|
|
def test_ast_query_handles_invalid_jsonpath(self):
|
|
"""RED: ast-query should handle invalid JSONPath expressions gracefully."""
|
|
result = self.runner.invoke(cli, [
|
|
'ast-query', str(self.test_file), '$.[invalid_syntax' # Missing closing bracket
|
|
])
|
|
|
|
# Should handle gracefully with helpful error message
|
|
assert result.exit_code != 0
|
|
assert any(phrase in result.output.lower() for phrase in [
|
|
"invalid", "syntax", "jsonpath", "error"
|
|
])
|
|
|
|
def test_ast_query_returns_empty_for_no_matches(self):
|
|
"""ast-query should handle queries with no matches gracefully."""
|
|
result = self.runner.invoke(cli, [
|
|
'ast-query', str(self.test_file), '$..nonexistent_field'
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
# Should indicate no matches found
|
|
assert any(phrase in result.output.lower() for phrase in [
|
|
"no matches", "empty", "not found", "[]"
|
|
])
|
|
|
|
def test_ast_query_leverages_cached_ast(self):
|
|
"""ast-query should use cached AST for performance."""
|
|
# Pre-populate cache
|
|
cache = ASTCache(self.cache_dir)
|
|
cache.cache_file(self.test_file)
|
|
|
|
with patch.object(ASTCache, 'load_cached_ast') as mock_get:
|
|
mock_get.return_value = [{"type": "heading_open", "tag": "h1"}]
|
|
|
|
result = self.runner.invoke(cli, [
|
|
'ast-query', str(self.test_file), '$.*.type'
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
# Should have used cache
|
|
mock_get.assert_called_once()
|
|
|
|
# ===== ast-stats command tests =====
|
|
|
|
def test_ast_stats_command_exists(self):
|
|
"""RED: ast-stats command should exist and be callable."""
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert "No such command" not in result.output
|
|
assert result.exit_code in [0, 1, 2]
|
|
|
|
|
|
def test_ast_stats_shows_heading_statistics(self):
|
|
"""ast-stats should show statistics about headings."""
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should show heading counts
|
|
assert any(word in result.output.lower() for word in ["headings", "h1", "h2", "h3"])
|
|
# Should show actual counts (our test file has 3 headings)
|
|
assert "3" in result.output or "three" in result.output.lower()
|
|
|
|
def test_ast_stats_shows_link_statistics(self):
|
|
"""ast-stats should show statistics about links."""
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should show link counts
|
|
assert "links" in result.output.lower() or "link" in result.output.lower()
|
|
# Our test file has 2 links
|
|
assert "2" in result.output
|
|
|
|
def test_ast_stats_shows_text_statistics(self):
|
|
"""ast-stats should show general text and structure statistics."""
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should show various statistics
|
|
statistics_keywords = [
|
|
"paragraphs", "words", "characters", "lists", "code", "blockquotes"
|
|
]
|
|
assert any(keyword in result.output.lower() for keyword in statistics_keywords)
|
|
|
|
def test_ast_stats_handles_empty_file(self):
|
|
"""RED: ast-stats should handle empty files gracefully."""
|
|
empty_file = Path(self.temp_dir) / "empty.md"
|
|
empty_file.write_text("")
|
|
|
|
result = self.runner.invoke(cli, ['ast-stats', str(empty_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should show zero statistics
|
|
assert "0" in result.output
|
|
assert any(phrase in result.output.lower() for phrase in [
|
|
"empty", "no content", "zero"
|
|
])
|
|
|
|
def test_ast_stats_leverages_cached_ast(self):
|
|
"""ast-stats should use cached AST for performance."""
|
|
cache = ASTCache(self.cache_dir)
|
|
cache.cache_file(self.test_file)
|
|
|
|
with patch.object(ASTCache, 'load_cached_ast') as mock_get:
|
|
mock_get.return_value = [
|
|
{"type": "heading_open", "tag": "h1"},
|
|
{"type": "link_open", "href": "test"}
|
|
]
|
|
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
mock_get.assert_called_once()
|
|
|
|
def test_ast_stats_provides_comprehensive_analysis(self):
|
|
"""ast-stats should provide comprehensive document analysis."""
|
|
result = self.runner.invoke(cli, ['ast-stats', str(self.test_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should provide multiple types of analysis
|
|
output_lower = result.output.lower()
|
|
analysis_types = [
|
|
"headings", "links", "lists", "paragraphs", "code"
|
|
]
|
|
# Should have at least 3 different types of analysis
|
|
matching_types = [t for t in analysis_types if t in output_lower]
|
|
assert len(matching_types) >= 3
|
|
|
|
# ===== Performance and Integration Tests =====
|
|
|
|
def test_ast_commands_integration_with_cache_system(self):
|
|
"""All AST commands should integrate seamlessly with existing cache system."""
|
|
# Test that all commands can handle cached vs non-cached scenarios
|
|
commands_and_args = [
|
|
['ast-show', str(self.test_file)],
|
|
['ast-query', str(self.test_file), '$.[0]'],
|
|
['ast-stats', str(self.test_file)]
|
|
]
|
|
|
|
for cmd_args in commands_and_args:
|
|
# First run (should create cache)
|
|
result1 = self.runner.invoke(cli, cmd_args)
|
|
assert result1.exit_code == 0
|
|
|
|
# Second run (should use cache)
|
|
result2 = self.runner.invoke(cli, cmd_args)
|
|
assert result2.exit_code == 0
|
|
# Results should be consistent
|
|
assert len(result2.output.strip()) > 0
|
|
|
|
def test_ast_commands_error_handling_consistency(self):
|
|
"""All AST commands should have consistent error handling."""
|
|
nonexistent_file = Path(self.temp_dir) / "nonexistent.md"
|
|
|
|
commands = [
|
|
['ast-show', str(nonexistent_file)],
|
|
['ast-query', str(nonexistent_file), '$.test'],
|
|
['ast-stats', str(nonexistent_file)]
|
|
]
|
|
|
|
for cmd_args in commands:
|
|
result = self.runner.invoke(cli, cmd_args)
|
|
# All should fail gracefully
|
|
assert result.exit_code != 0
|
|
# All should provide meaningful error messages
|
|
assert len(result.output.strip()) > 0
|
|
assert "error" in result.output.lower() or "not found" in result.output.lower() |