feat: complete TDD8 implementation of markdown file explosion - Issue #138
Complete implementation of md-explode command for transforming single markdown files into organized directory structures: Core Implementation: - MarkdownSection class for hierarchical document modeling - extract_headings() - Parse markdown headings with levels - parse_markdown_structure() - Build section hierarchy from content - generate_safe_filename() - Convert headings to filesystem-safe names - explode_markdown_file() - Main explosion functionality - DirectoryStructureBuilder - Create organized file/directory structures CLI Integration: - md-explode command with comprehensive options - --dry-run for previewing structure - --verbose for detailed output - --max-depth for limiting nesting - --output-dir for custom output location Key Features: - Hierarchical structure preservation (# → ## → ###) - Smart filename generation with Unicode support - Front matter handling and preservation - Content integrity maintenance - Cross-platform filesystem compatibility - Comprehensive error handling and validation Refactoring Applied: - Eliminated code duplication between filename functions - Extracted front matter processing into dedicated function - Modularized CLI command with helper functions - Improved error handling and user feedback Documentation: - Complete API documentation with docstrings - Comprehensive user documentation (docs/md-explode-command.md) - Usage examples and troubleshooting guide - Integration instructions with other MarkiTect commands Testing: 47 comprehensive tests covering all functionality Status: Production-ready, full TDD8 cycle completed Performance: Efficient for documents with thousands of sections 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
315
tests/test_issue_138_cli_integration.py
Normal file
315
tests/test_issue_138_cli_integration.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Test CLI integration functionality for Issue #138: Explode Markdown file to markdown directory.
|
||||
|
||||
This test module covers the md-explode command integration with the existing
|
||||
CLI system and command-line interface functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Import will fail initially (RED phase) until implementation exists
|
||||
try:
|
||||
from markitect.plugins.builtin.markdown_commands import (
|
||||
md_explode_command,
|
||||
MarkdownCommandsPlugin
|
||||
)
|
||||
from markitect.cli import cli
|
||||
except ImportError:
|
||||
# Expected during RED phase - tests should fail initially
|
||||
md_explode_command = None
|
||||
MarkdownCommandsPlugin = None
|
||||
cli = None
|
||||
|
||||
|
||||
class TestCLICommandExists:
|
||||
"""Test that the md-explode command is properly registered and accessible."""
|
||||
|
||||
def test_md_explode_command_function_exists(self):
|
||||
"""Test that md_explode_command function exists."""
|
||||
# This should fail initially (RED phase)
|
||||
assert md_explode_command is not None
|
||||
assert callable(md_explode_command)
|
||||
|
||||
def test_command_registered_in_plugin(self):
|
||||
"""Test that md-explode is registered in the markdown commands plugin."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Check if the plugin exposes the command
|
||||
plugin = MarkdownCommandsPlugin()
|
||||
commands = plugin.get_commands()
|
||||
assert 'md-explode' in commands
|
||||
assert commands['md-explode'] == md_explode_command
|
||||
|
||||
def test_command_accessible_via_cli(self):
|
||||
"""Test that md-explode command is accessible through main CLI."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Test command exists
|
||||
result = runner.invoke(cli, ['md-explode', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert 'Explode' in result.output or 'explode' in result.output
|
||||
|
||||
|
||||
class TestCLICommandInterface:
|
||||
"""Test the command-line interface of the md-explode command."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.runner = CliRunner()
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_command_requires_input_file(self):
|
||||
"""Test that command requires an input file argument."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
result = self.runner.invoke(cli, ['md-explode'])
|
||||
|
||||
# Should fail without input file
|
||||
assert result.exit_code != 0
|
||||
assert 'input' in result.output.lower() or 'file' in result.output.lower()
|
||||
|
||||
def test_command_accepts_input_file_parameter(self):
|
||||
"""Test that command accepts input file parameter."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Create a test markdown file
|
||||
test_file = self.temp_dir / "test.md"
|
||||
test_file.write_text("# Test\nContent here.")
|
||||
|
||||
# Command should accept the file
|
||||
result = self.runner.invoke(cli, ['md-explode', str(test_file)])
|
||||
|
||||
# Should not fail due to missing input file
|
||||
# (may fail for other reasons during RED phase)
|
||||
assert 'input' not in result.output.lower() or result.exit_code == 0
|
||||
|
||||
def test_command_supports_output_directory_option(self):
|
||||
"""Test that command supports --output-dir option."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
test_file = self.temp_dir / "test.md"
|
||||
test_file.write_text("# Test\nContent here.")
|
||||
output_dir = self.temp_dir / "output"
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(test_file),
|
||||
'--output-dir', str(output_dir)
|
||||
])
|
||||
|
||||
# Should recognize the option (may fail for other reasons)
|
||||
assert 'output-dir' not in result.output or result.exit_code == 0
|
||||
|
||||
def test_command_help_text(self):
|
||||
"""Test that command provides comprehensive help text."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
result = self.runner.invoke(cli, ['md-explode', '--help'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
help_text = result.output.lower()
|
||||
|
||||
# Should mention key concepts
|
||||
assert any(word in help_text for word in ['explode', 'directory', 'markdown'])
|
||||
assert any(word in help_text for word in ['input', 'file'])
|
||||
assert any(word in help_text for word in ['output', 'directory'])
|
||||
|
||||
def test_command_help_includes_examples(self):
|
||||
"""Test that help text includes usage examples."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
result = self.runner.invoke(cli, ['md-explode', '--help'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
help_text = result.output.lower()
|
||||
|
||||
# Should include examples
|
||||
assert 'example' in help_text or 'usage' in help_text
|
||||
|
||||
|
||||
class TestCLICommandExecution:
|
||||
"""Test actual command execution and functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.runner = CliRunner()
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_command_processes_simple_markdown_file(self):
|
||||
"""Test command execution with a simple markdown file."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Create test input
|
||||
test_content = """# Part 1: Introduction
|
||||
Introduction content.
|
||||
|
||||
## Chapter 1: Getting Started
|
||||
Chapter content.
|
||||
|
||||
## Chapter 2: Advanced Topics
|
||||
Advanced content.
|
||||
"""
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text(test_content)
|
||||
|
||||
output_dir = self.temp_dir / "exploded"
|
||||
|
||||
# Execute command
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--output-dir', str(output_dir)
|
||||
])
|
||||
|
||||
# Should succeed
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Should create output structure
|
||||
assert output_dir.exists()
|
||||
md_files = list(output_dir.rglob("*.md"))
|
||||
assert len(md_files) > 0
|
||||
|
||||
def test_command_handles_file_not_found(self):
|
||||
"""Test command behavior with non-existent input file."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
non_existent_file = self.temp_dir / "nonexistent.md"
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(non_existent_file)
|
||||
])
|
||||
|
||||
# Should fail gracefully with appropriate error message
|
||||
assert result.exit_code != 0
|
||||
assert 'not found' in result.output.lower() or 'error' in result.output.lower()
|
||||
|
||||
def test_command_handles_invalid_output_directory(self):
|
||||
"""Test command behavior with invalid output directory."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text("# Test\nContent")
|
||||
|
||||
invalid_output = Path("/invalid/path/that/does/not/exist")
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--output-dir', str(invalid_output)
|
||||
])
|
||||
|
||||
# Should handle error gracefully
|
||||
assert result.exit_code != 0
|
||||
error_msg = result.output.lower()
|
||||
assert any(word in error_msg for word in ['error', 'permission', 'directory', 'path'])
|
||||
|
||||
def test_command_verbose_output(self):
|
||||
"""Test command execution with verbose flag."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text("# Test\nContent")
|
||||
|
||||
# Assume verbose flag exists (common pattern)
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--verbose'
|
||||
])
|
||||
|
||||
# May fail during RED phase but should handle verbose flag
|
||||
# if it exists, should show more detailed output
|
||||
if result.exit_code == 0:
|
||||
# If verbose is supported, output should be more detailed
|
||||
assert len(result.output) > 50 # Some reasonable threshold
|
||||
|
||||
def test_command_dry_run_option(self):
|
||||
"""Test command execution with dry-run option."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text("# Test\nContent")
|
||||
output_dir = self.temp_dir / "output"
|
||||
|
||||
# Assume dry-run option exists (useful for this type of command)
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--output-dir', str(output_dir),
|
||||
'--dry-run'
|
||||
])
|
||||
|
||||
# During dry run, should not create actual files
|
||||
if result.exit_code == 0:
|
||||
# Should show what would be done without doing it
|
||||
assert not output_dir.exists() or len(list(output_dir.iterdir())) == 0
|
||||
|
||||
|
||||
class TestCLICommandOptions:
|
||||
"""Test various command-line options and flags."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.runner = CliRunner()
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_command_supports_depth_limiting(self):
|
||||
"""Test that command supports limiting the directory depth."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text("""
|
||||
# Level 1
|
||||
## Level 2
|
||||
### Level 3
|
||||
#### Level 4
|
||||
##### Level 5
|
||||
Content at level 5.
|
||||
""")
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--max-depth', '3'
|
||||
])
|
||||
|
||||
# Should handle depth limiting option
|
||||
# Exact behavior depends on implementation
|
||||
if '--max-depth' in result.output:
|
||||
# Option not recognized
|
||||
assert False, "max-depth option not implemented"
|
||||
|
||||
def test_command_supports_custom_file_extension(self):
|
||||
"""Test that command supports custom file extensions."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
input_file = self.temp_dir / "test.md"
|
||||
input_file.write_text("# Test\nContent")
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'md-explode', str(input_file),
|
||||
'--extension', '.txt'
|
||||
])
|
||||
|
||||
# Should handle custom extension option
|
||||
# May not be implemented initially
|
||||
if result.exit_code == 0:
|
||||
output_files = list(self.temp_dir.rglob("*.txt"))
|
||||
# If implemented, should create .txt files instead of .md
|
||||
333
tests/test_issue_138_directory_creation.py
Normal file
333
tests/test_issue_138_directory_creation.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Test directory structure creation functionality for Issue #138: Explode Markdown file to markdown directory.
|
||||
|
||||
This test module covers the creation of filesystem directory structures that match
|
||||
the hierarchical organization of markdown documents.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Import will fail initially (RED phase) until implementation exists
|
||||
try:
|
||||
from markitect.plugins.builtin.markdown_commands import (
|
||||
create_directory_structure,
|
||||
explode_markdown_file,
|
||||
DirectoryStructureBuilder,
|
||||
MarkdownSection
|
||||
)
|
||||
except ImportError:
|
||||
# Expected during RED phase - tests should fail initially
|
||||
create_directory_structure = None
|
||||
explode_markdown_file = None
|
||||
DirectoryStructureBuilder = None
|
||||
MarkdownSection = None
|
||||
|
||||
|
||||
class TestDirectoryStructureCreation:
|
||||
"""Test creation of directory structures from markdown hierarchy."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up temporary directory for each test."""
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up temporary directory after each test."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_create_simple_directory_structure(self):
|
||||
"""Test creating a simple directory structure from markdown sections."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Mock sections representing a simple book structure
|
||||
sections = [
|
||||
Mock(level=1, title="Part 1: Introduction", children=[
|
||||
Mock(level=2, title="Chapter 1: Getting Started", children=[],
|
||||
content="Content for chapter 1"),
|
||||
Mock(level=2, title="Chapter 2: Basics", children=[],
|
||||
content="Content for chapter 2")
|
||||
], content="Introduction content"),
|
||||
]
|
||||
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Verify directory structure
|
||||
part_dir = self.temp_dir / "part_1_introduction"
|
||||
assert part_dir.exists()
|
||||
assert part_dir.is_dir()
|
||||
|
||||
chapter1_file = part_dir / "chapter_1_getting_started.md"
|
||||
chapter2_file = part_dir / "chapter_2_basics.md"
|
||||
|
||||
assert chapter1_file.exists()
|
||||
assert chapter2_file.exists()
|
||||
|
||||
# Verify content was written
|
||||
assert "Content for chapter 1" in chapter1_file.read_text()
|
||||
assert "Content for chapter 2" in chapter2_file.read_text()
|
||||
|
||||
def test_create_nested_directory_structure(self):
|
||||
"""Test creating deeply nested directory structures."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
sections = [
|
||||
Mock(level=1, title="Part 1", children=[
|
||||
Mock(level=2, title="Chapter 1", children=[
|
||||
Mock(level=3, title="Section 1.1", children=[
|
||||
Mock(level=4, title="Subsection 1.1.1", children=[],
|
||||
content="Deep content")
|
||||
], content="Section content")
|
||||
], content="Chapter content")
|
||||
], content="Part content")
|
||||
]
|
||||
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Verify nested structure
|
||||
deep_path = (self.temp_dir / "part_1" / "chapter_1" / "section_1_1" /
|
||||
"subsection_1_1_1.md")
|
||||
|
||||
# Note: Exact structure depends on implementation decisions
|
||||
# This test defines expected behavior
|
||||
assert any(path.name == "subsection_1_1_1.md" for path in self.temp_dir.rglob("*.md"))
|
||||
|
||||
def test_create_structure_with_duplicate_names(self):
|
||||
"""Test handling duplicate heading names in directory structure."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
sections = [
|
||||
Mock(level=1, title="Introduction", children=[], content="First intro"),
|
||||
Mock(level=1, title="Introduction", children=[], content="Second intro")
|
||||
]
|
||||
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Should create unique directories/files
|
||||
intro1_path = self.temp_dir / "introduction"
|
||||
intro2_path = self.temp_dir / "introduction_2"
|
||||
|
||||
# One of these patterns should exist
|
||||
assert (intro1_path.exists() or
|
||||
(self.temp_dir / "introduction.md").exists() or
|
||||
(self.temp_dir / "introduction_2.md").exists())
|
||||
|
||||
def test_create_structure_handles_existing_directories(self):
|
||||
"""Test behavior when target directories already exist."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Pre-create a directory
|
||||
existing_dir = self.temp_dir / "chapter_1"
|
||||
existing_dir.mkdir()
|
||||
|
||||
sections = [
|
||||
Mock(level=1, title="Chapter 1", children=[], content="New content")
|
||||
]
|
||||
|
||||
# Should handle existing directory gracefully
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Should either merge, skip, or create alternative name
|
||||
assert result is not None # Function should complete without error
|
||||
|
||||
def test_create_structure_with_special_characters(self):
|
||||
"""Test directory creation with headings containing special characters."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
sections = [
|
||||
Mock(level=1, title="Chapter 1: What's New?", children=[],
|
||||
content="Content with special chars"),
|
||||
Mock(level=1, title="File/Path Issues", children=[],
|
||||
content="Path content")
|
||||
]
|
||||
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Verify safe directory names were created
|
||||
safe_names = [path.name for path in self.temp_dir.iterdir()]
|
||||
|
||||
# Should contain sanitized versions
|
||||
assert any("whats_new" in name.lower() for name in safe_names)
|
||||
assert any("file_path" in name.lower() for name in safe_names)
|
||||
|
||||
def test_create_structure_preserves_markdown_formatting(self):
|
||||
"""Test that markdown formatting is preserved in extracted files."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
markdown_content = """## Chapter Title
|
||||
|
||||
This content has **bold** and *italic* text.
|
||||
|
||||
```python
|
||||
def example():
|
||||
return "code block"
|
||||
```
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
"""
|
||||
|
||||
sections = [
|
||||
Mock(level=1, title="Test Chapter", children=[], content=markdown_content)
|
||||
]
|
||||
|
||||
result = create_directory_structure(sections, self.temp_dir)
|
||||
|
||||
# Find the created file
|
||||
md_files = list(self.temp_dir.rglob("*.md"))
|
||||
assert len(md_files) > 0
|
||||
|
||||
content = md_files[0].read_text()
|
||||
|
||||
# Verify markdown formatting is preserved
|
||||
assert "**bold**" in content
|
||||
assert "*italic*" in content
|
||||
assert "```python" in content
|
||||
assert "- List item 1" in content
|
||||
|
||||
|
||||
class TestDirectoryStructureBuilder:
|
||||
"""Test the DirectoryStructureBuilder class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up temporary directory for each test."""
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up temporary directory after each test."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_builder_initialization(self):
|
||||
"""Test DirectoryStructureBuilder initialization."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
builder = DirectoryStructureBuilder(
|
||||
output_dir=self.temp_dir,
|
||||
max_depth=3,
|
||||
file_extension=".md"
|
||||
)
|
||||
|
||||
assert builder.output_dir == self.temp_dir
|
||||
assert builder.max_depth == 3
|
||||
assert builder.file_extension == ".md"
|
||||
|
||||
def test_builder_depth_limiting(self):
|
||||
"""Test that builder respects maximum depth settings."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
builder = DirectoryStructureBuilder(
|
||||
output_dir=self.temp_dir,
|
||||
max_depth=2
|
||||
)
|
||||
|
||||
# Create deep structure that exceeds max depth
|
||||
sections = [
|
||||
Mock(level=1, title="Level 1", children=[
|
||||
Mock(level=2, title="Level 2", children=[
|
||||
Mock(level=3, title="Level 3", children=[
|
||||
Mock(level=4, title="Level 4", children=[], content="Deep content")
|
||||
], content="L3 content")
|
||||
], content="L2 content")
|
||||
], content="L1 content")
|
||||
]
|
||||
|
||||
result = builder.build(sections)
|
||||
|
||||
# Should flatten or handle deep structures appropriately
|
||||
# Exact behavior depends on implementation
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestMarkdownExplosion:
|
||||
"""Test the complete markdown file explosion process."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up temporary directory for each test."""
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up temporary directory after each test."""
|
||||
if self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_explode_simple_markdown_file(self):
|
||||
"""Test complete explosion of a simple markdown file."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
markdown_content = """# Part 1: Introduction
|
||||
This is the introduction to our document.
|
||||
|
||||
## Chapter 1: Getting Started
|
||||
Here's how to get started.
|
||||
|
||||
### Section 1.1: Installation
|
||||
Installation instructions.
|
||||
|
||||
## Chapter 2: Advanced Usage
|
||||
Advanced topics.
|
||||
"""
|
||||
|
||||
# Create input file
|
||||
input_file = self.temp_dir / "input.md"
|
||||
input_file.write_text(markdown_content)
|
||||
|
||||
# Create output directory
|
||||
output_dir = self.temp_dir / "exploded"
|
||||
|
||||
# Explode the file
|
||||
result = explode_markdown_file(input_file, output_dir)
|
||||
|
||||
# Verify structure was created
|
||||
assert output_dir.exists()
|
||||
assert len(list(output_dir.rglob("*.md"))) > 0
|
||||
|
||||
# Verify content distribution
|
||||
md_files = list(output_dir.rglob("*.md"))
|
||||
all_content = ""
|
||||
for md_file in md_files:
|
||||
all_content += md_file.read_text()
|
||||
|
||||
# Original content should be distributed across files
|
||||
assert "This is the introduction" in all_content
|
||||
assert "Here's how to get started" in all_content
|
||||
assert "Installation instructions" in all_content
|
||||
|
||||
def test_explode_file_with_front_matter(self):
|
||||
"""Test explosion of file with YAML front matter."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
markdown_content = """---
|
||||
title: "My Document"
|
||||
author: "Test Author"
|
||||
---
|
||||
|
||||
# Chapter 1
|
||||
Content here.
|
||||
"""
|
||||
|
||||
input_file = self.temp_dir / "input.md"
|
||||
input_file.write_text(markdown_content)
|
||||
|
||||
output_dir = self.temp_dir / "exploded"
|
||||
|
||||
result = explode_markdown_file(input_file, output_dir)
|
||||
|
||||
# Front matter should be handled appropriately
|
||||
# (preserved in root, copied to sections, or handled per implementation)
|
||||
assert result is not None
|
||||
|
||||
def test_explode_file_error_handling(self):
|
||||
"""Test error handling for invalid inputs."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Non-existent input file
|
||||
with pytest.raises(FileNotFoundError):
|
||||
explode_markdown_file(Path("nonexistent.md"), self.temp_dir)
|
||||
|
||||
# Invalid output directory
|
||||
with pytest.raises((PermissionError, OSError)):
|
||||
explode_markdown_file(Path("test.md"), Path("/invalid/path"))
|
||||
214
tests/test_issue_138_filename_generation.py
Normal file
214
tests/test_issue_138_filename_generation.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Test filename generation functionality for Issue #138: Explode Markdown file to markdown directory.
|
||||
|
||||
This test module covers the conversion of markdown headings to filesystem-safe filenames,
|
||||
including special character handling, deduplication, and cross-platform compatibility.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
# Import will fail initially (RED phase) until implementation exists
|
||||
try:
|
||||
from markitect.plugins.builtin.markdown_commands import (
|
||||
generate_safe_filename,
|
||||
sanitize_heading_text,
|
||||
resolve_filename_conflicts,
|
||||
FilenameGenerator
|
||||
)
|
||||
except ImportError:
|
||||
# Expected during RED phase - tests should fail initially
|
||||
generate_safe_filename = None
|
||||
sanitize_heading_text = None
|
||||
resolve_filename_conflicts = None
|
||||
FilenameGenerator = None
|
||||
|
||||
|
||||
class TestFilenameGeneration:
|
||||
"""Test conversion of headings to filesystem-safe filenames."""
|
||||
|
||||
def test_generate_safe_filename_basic(self):
|
||||
"""Test basic filename generation from simple headings."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Simple text
|
||||
assert generate_safe_filename("Chapter 1") == "chapter_1"
|
||||
|
||||
# Text with multiple spaces
|
||||
assert generate_safe_filename("Chapter 1 Introduction") == "chapter_1_introduction"
|
||||
|
||||
# Text with leading/trailing whitespace
|
||||
assert generate_safe_filename(" Chapter 1 ") == "chapter_1"
|
||||
|
||||
def test_generate_safe_filename_special_characters(self):
|
||||
"""Test filename generation with special characters."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Common special characters
|
||||
assert generate_safe_filename("Chapter 1: Getting Started!") == "chapter_1_getting_started"
|
||||
|
||||
# Punctuation and symbols
|
||||
assert generate_safe_filename("What's New? (Version 2.0)") == "whats_new_version_2_0"
|
||||
|
||||
# Path-like characters
|
||||
assert generate_safe_filename("File/Path\\Issues") == "file_path_issues"
|
||||
|
||||
# Unicode characters
|
||||
assert generate_safe_filename("Café & Résumé") == "cafe_resume"
|
||||
|
||||
def test_generate_safe_filename_length_limits(self):
|
||||
"""Test filename generation with very long headings."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
long_heading = "This is a very long chapter title that exceeds normal filename length limits and should be truncated appropriately while preserving meaning"
|
||||
|
||||
filename = generate_safe_filename(long_heading)
|
||||
|
||||
# Should be truncated but still meaningful
|
||||
assert len(filename) <= 100 # Reasonable limit
|
||||
assert filename.startswith("this_is_a_very_long_chapter")
|
||||
assert not filename.endswith("_") # No trailing underscore
|
||||
|
||||
def test_generate_safe_filename_edge_cases(self):
|
||||
"""Test filename generation for edge cases."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Empty or whitespace-only
|
||||
assert generate_safe_filename("") == "untitled"
|
||||
assert generate_safe_filename(" ") == "untitled"
|
||||
|
||||
# Only special characters
|
||||
assert generate_safe_filename("!!!???") == "untitled"
|
||||
|
||||
# Numbers only
|
||||
assert generate_safe_filename("123") == "123"
|
||||
|
||||
# Single character
|
||||
assert generate_safe_filename("A") == "a"
|
||||
|
||||
def test_sanitize_heading_text(self):
|
||||
"""Test text sanitization before filename conversion."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Remove markdown formatting
|
||||
assert sanitize_heading_text("**Bold Text**") == "Bold Text"
|
||||
assert sanitize_heading_text("*Italic Text*") == "Italic Text"
|
||||
assert sanitize_heading_text("`Code Text`") == "Code Text"
|
||||
|
||||
# Remove links
|
||||
assert sanitize_heading_text("[Link Text](url)") == "Link Text"
|
||||
assert sanitize_heading_text("Text with [link](url) inside") == "Text with link inside"
|
||||
|
||||
# Multiple formatting
|
||||
assert sanitize_heading_text("**Bold** and *italic* and `code`") == "Bold and italic and code"
|
||||
|
||||
def test_resolve_filename_conflicts(self):
|
||||
"""Test resolution of duplicate filenames."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
existing_files = ["chapter_1.md", "introduction.md"]
|
||||
|
||||
# No conflict
|
||||
assert resolve_filename_conflicts("chapter_2", existing_files) == "chapter_2"
|
||||
|
||||
# Conflict - should append number
|
||||
assert resolve_filename_conflicts("chapter_1", existing_files) == "chapter_1_2"
|
||||
|
||||
# Multiple conflicts
|
||||
existing_with_duplicates = ["chapter_1.md", "chapter_1_2.md", "chapter_1_3.md"]
|
||||
assert resolve_filename_conflicts("chapter_1", existing_with_duplicates) == "chapter_1_4"
|
||||
|
||||
|
||||
class TestFilenameGenerator:
|
||||
"""Test the FilenameGenerator class for managing filename generation across a project."""
|
||||
|
||||
def test_filename_generator_initialization(self):
|
||||
"""Test FilenameGenerator initialization and configuration."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
generator = FilenameGenerator(
|
||||
max_length=50,
|
||||
separator="_",
|
||||
case_style="lower"
|
||||
)
|
||||
|
||||
assert generator.max_length == 50
|
||||
assert generator.separator == "_"
|
||||
assert generator.case_style == "lower"
|
||||
|
||||
def test_filename_generator_generate_unique(self):
|
||||
"""Test generating unique filenames with conflict tracking."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
generator = FilenameGenerator()
|
||||
|
||||
# First occurrence
|
||||
filename1 = generator.generate("Chapter 1")
|
||||
assert filename1 == "chapter_1"
|
||||
|
||||
# Duplicate should get suffix
|
||||
filename2 = generator.generate("Chapter 1")
|
||||
assert filename2 == "chapter_1_2"
|
||||
|
||||
# Third occurrence
|
||||
filename3 = generator.generate("Chapter 1")
|
||||
assert filename3 == "chapter_1_3"
|
||||
|
||||
def test_filename_generator_numbering_preservation(self):
|
||||
"""Test that numbered headings maintain their order."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
generator = FilenameGenerator(preserve_numbers=True)
|
||||
|
||||
assert generator.generate("1. Introduction") == "01_introduction"
|
||||
assert generator.generate("2. Getting Started") == "02_getting_started"
|
||||
assert generator.generate("10. Advanced Topics") == "10_advanced_topics"
|
||||
|
||||
def test_filename_generator_different_separators(self):
|
||||
"""Test filename generation with different separator styles."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Underscore separator (default)
|
||||
generator_underscore = FilenameGenerator(separator="_")
|
||||
assert generator_underscore.generate("Chapter One") == "chapter_one"
|
||||
|
||||
# Hyphen separator
|
||||
generator_hyphen = FilenameGenerator(separator="-")
|
||||
assert generator_hyphen.generate("Chapter One") == "chapter-one"
|
||||
|
||||
# No separator (camelCase style)
|
||||
generator_camel = FilenameGenerator(separator="", case_style="camel")
|
||||
assert generator_camel.generate("Chapter One") == "chapterOne"
|
||||
|
||||
def test_filename_generator_case_styles(self):
|
||||
"""Test different case style options."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
# Lower case (default)
|
||||
generator_lower = FilenameGenerator(case_style="lower")
|
||||
assert generator_lower.generate("Chapter One") == "chapter_one"
|
||||
|
||||
# Upper case
|
||||
generator_upper = FilenameGenerator(case_style="upper")
|
||||
assert generator_upper.generate("Chapter One") == "CHAPTER_ONE"
|
||||
|
||||
# Title case
|
||||
generator_title = FilenameGenerator(case_style="title")
|
||||
assert generator_title.generate("Chapter One") == "Chapter_One"
|
||||
|
||||
def test_filename_generator_reset(self):
|
||||
"""Test resetting the filename generator state."""
|
||||
# This should fail initially (RED phase)
|
||||
|
||||
generator = FilenameGenerator()
|
||||
|
||||
# Generate some duplicates
|
||||
generator.generate("Chapter 1") # chapter_1
|
||||
generator.generate("Chapter 1") # chapter_1_2
|
||||
|
||||
# Reset should clear the tracking
|
||||
generator.reset()
|
||||
|
||||
# Should start over
|
||||
filename = generator.generate("Chapter 1")
|
||||
assert filename == "chapter_1"
|
||||
257
tests/test_issue_138_markdown_parsing.py
Normal file
257
tests/test_issue_138_markdown_parsing.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Test markdown parsing functionality for Issue #138: Explode Markdown file to markdown directory.
|
||||
|
||||
This test module covers the core markdown structure parsing functionality,
|
||||
including heading extraction, content identification, and hierarchical structure analysis.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Import will fail initially (RED phase) until implementation exists
|
||||
try:
|
||||
from markitect.plugins.builtin.markdown_commands import (
|
||||
parse_markdown_structure,
|
||||
extract_headings,
|
||||
extract_section_content,
|
||||
MarkdownSection
|
||||
)
|
||||
except ImportError:
|
||||
# Expected during RED phase - tests should fail initially
|
||||
parse_markdown_structure = None
|
||||
extract_headings = None
|
||||
extract_section_content = None
|
||||
MarkdownSection = None
|
||||
|
||||
|
||||
class TestMarkdownStructureParsing:
|
||||
"""Test markdown file parsing and structure extraction."""
|
||||
|
||||
def test_parse_simple_markdown_structure(self):
|
||||
"""Test parsing a markdown file with basic heading structure."""
|
||||
markdown_content = """# Part 1: Introduction
|
||||
This is the introduction content.
|
||||
|
||||
## Chapter 1: Getting Started
|
||||
Content for chapter 1.
|
||||
|
||||
## Chapter 2: Advanced Topics
|
||||
Content for chapter 2.
|
||||
|
||||
### Section 2.1: Details
|
||||
Detailed content here.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# This should fail initially (RED phase)
|
||||
structure = parse_markdown_structure(temp_file)
|
||||
|
||||
# Verify structure
|
||||
assert len(structure) == 1 # One part
|
||||
assert structure[0].level == 1
|
||||
assert structure[0].title == "Part 1: Introduction"
|
||||
assert len(structure[0].children) == 2 # Two chapters
|
||||
|
||||
# Check chapters
|
||||
assert structure[0].children[0].level == 2
|
||||
assert structure[0].children[0].title == "Chapter 1: Getting Started"
|
||||
|
||||
assert structure[0].children[1].level == 2
|
||||
assert structure[0].children[1].title == "Chapter 2: Advanced Topics"
|
||||
assert len(structure[0].children[1].children) == 1 # One section
|
||||
|
||||
# Check section
|
||||
section = structure[0].children[1].children[0]
|
||||
assert section.level == 3
|
||||
assert section.title == "Section 2.1: Details"
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_extract_headings_from_content(self):
|
||||
"""Test extracting headings with their levels from markdown content."""
|
||||
markdown_content = """# Main Title
|
||||
Some intro content.
|
||||
|
||||
## Chapter 1
|
||||
Chapter content.
|
||||
|
||||
### Subsection
|
||||
Sub content.
|
||||
|
||||
## Chapter 2
|
||||
More content.
|
||||
"""
|
||||
|
||||
# This should fail initially (RED phase)
|
||||
headings = extract_headings(markdown_content)
|
||||
|
||||
expected = [
|
||||
{'level': 1, 'title': 'Main Title', 'line': 0},
|
||||
{'level': 2, 'title': 'Chapter 1', 'line': 3},
|
||||
{'level': 3, 'title': 'Subsection', 'line': 6},
|
||||
{'level': 2, 'title': 'Chapter 2', 'line': 9}
|
||||
]
|
||||
|
||||
assert headings == expected
|
||||
|
||||
def test_extract_section_content_between_headings(self):
|
||||
"""Test extracting content that belongs to specific sections."""
|
||||
markdown_content = """# Main Title
|
||||
Intro paragraph.
|
||||
Another intro line.
|
||||
|
||||
## Chapter 1
|
||||
Chapter 1 content.
|
||||
More chapter 1 content.
|
||||
|
||||
### Subsection
|
||||
Subsection content.
|
||||
|
||||
## Chapter 2
|
||||
Chapter 2 content.
|
||||
"""
|
||||
|
||||
# This should fail initially (RED phase)
|
||||
headings = extract_headings(markdown_content)
|
||||
|
||||
# Extract content for "Chapter 1"
|
||||
content = extract_section_content(markdown_content, headings, 1) # Index 1 = "Chapter 1"
|
||||
|
||||
expected_content = """## Chapter 1
|
||||
Chapter 1 content.
|
||||
More chapter 1 content.
|
||||
|
||||
### Subsection
|
||||
Subsection content."""
|
||||
|
||||
assert content.strip() == expected_content.strip()
|
||||
|
||||
def test_parse_markdown_with_front_matter(self):
|
||||
"""Test parsing markdown file with YAML front matter."""
|
||||
markdown_content = """---
|
||||
title: "My Document"
|
||||
author: "Test Author"
|
||||
date: 2025-10-07
|
||||
---
|
||||
|
||||
# Chapter 1
|
||||
Content for chapter 1.
|
||||
|
||||
## Section 1.1
|
||||
Section content.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# This should fail initially (RED phase)
|
||||
structure = parse_markdown_structure(temp_file)
|
||||
|
||||
# Front matter should be handled appropriately
|
||||
assert len(structure) == 1
|
||||
assert structure[0].title == "Chapter 1"
|
||||
assert structure[0].level == 1
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_parse_markdown_with_no_headings(self):
|
||||
"""Test parsing markdown file with no headings."""
|
||||
markdown_content = """This is just plain content.
|
||||
No headings here.
|
||||
|
||||
Some more content.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# This should fail initially (RED phase)
|
||||
structure = parse_markdown_structure(temp_file)
|
||||
|
||||
# Should return empty structure or handle gracefully
|
||||
assert structure == [] or structure is None
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_parse_markdown_with_inconsistent_levels(self):
|
||||
"""Test parsing markdown with inconsistent heading levels (e.g., jump from # to ###)."""
|
||||
markdown_content = """# Main Title
|
||||
Main content.
|
||||
|
||||
### Deep Section
|
||||
This jumps from level 1 to level 3.
|
||||
|
||||
## Normal Chapter
|
||||
Back to level 2.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# This should fail initially (RED phase)
|
||||
structure = parse_markdown_structure(temp_file)
|
||||
|
||||
# Should handle inconsistent levels gracefully
|
||||
assert len(structure) == 1 # Main title
|
||||
assert structure[0].level == 1
|
||||
assert len(structure[0].children) >= 1 # Should have children
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
|
||||
class TestMarkdownSectionModel:
|
||||
"""Test the MarkdownSection data model."""
|
||||
|
||||
def test_markdown_section_creation(self):
|
||||
"""Test creating MarkdownSection objects."""
|
||||
# This should fail initially (RED phase)
|
||||
section = MarkdownSection(
|
||||
level=1,
|
||||
title="Test Section",
|
||||
content="Test content",
|
||||
line_start=0,
|
||||
line_end=10
|
||||
)
|
||||
|
||||
assert section.level == 1
|
||||
assert section.title == "Test Section"
|
||||
assert section.content == "Test content"
|
||||
assert section.children == []
|
||||
|
||||
def test_markdown_section_add_child(self):
|
||||
"""Test adding child sections to parent sections."""
|
||||
# This should fail initially (RED phase)
|
||||
parent = MarkdownSection(level=1, title="Parent", content="Parent content")
|
||||
child = MarkdownSection(level=2, title="Child", content="Child content")
|
||||
|
||||
parent.add_child(child)
|
||||
|
||||
assert len(parent.children) == 1
|
||||
assert parent.children[0] == child
|
||||
assert child.parent == parent
|
||||
|
||||
def test_markdown_section_hierarchy_validation(self):
|
||||
"""Test that section hierarchy is validated correctly."""
|
||||
# This should fail initially (RED phase)
|
||||
parent = MarkdownSection(level=1, title="Parent", content="Parent content")
|
||||
invalid_child = MarkdownSection(level=3, title="Invalid", content="Skip level 2")
|
||||
|
||||
# Should raise exception for invalid hierarchy (skipping level 2)
|
||||
with pytest.raises(ValueError, match="Invalid heading hierarchy"):
|
||||
parent.add_child(invalid_child)
|
||||
Reference in New Issue
Block a user