Files
markitect-main/tests/test_l4_service_ast_analysis.py
tegwick c25795fb79
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
refactor: Remove deprecated query and schema commands and update all tests
- 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>
2025-09-30 23:33:43 +02:00

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()