feat: reorganize tests by capability with separate test targets
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
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
Separate capability-specific tests from core system tests to establish clear test organization and separation of concerns. ## Test Reorganization: - **markitect-content tests**: Moved 6 tests to capabilities/markitect-content/tests/ - **markitect-finance tests**: Moved 7 tests to markitect/finance/tests/ - **markitect-query tests**: Moved 1 test to markitect/query_paradigms/tests/ - **markitect-graphql tests**: Moved 2 tests to markitect/graphql/tests/ - **markitect-plugins tests**: Moved 2 tests to markitect/plugins/tests/ ## Makefile Updates: - **make test**: Excludes capability tests, runs only core system tests - **make test-capabilities**: Runs all capability tests - **make test-capability-***: Individual capability test targets - Updated all test targets (test-red, test-green, test-ultra-fast, test-perf) - Added capability test targets to help documentation ## Benefits: - Clear separation between core system tests and capability-specific tests - Faster core test execution (capability tests not run by default) - Individual capability testing for focused development - Supports future capability extraction workflow - Maintains capability test independence Test verification: - Core tests: 1291 tests (capability tests excluded) - Finance capability: 143 tests working independently - Content capability: 79 tests working independently 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,393 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect cost tracking CLI commands.
|
||||
|
||||
This module tests the command-line interface for cost management including:
|
||||
- Cost report generation commands
|
||||
- Cost item management commands
|
||||
- Category management commands
|
||||
- Period cost calculations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.finance.cli import cost_commands
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem
|
||||
from markitect.finance.models import FinanceModels
|
||||
|
||||
|
||||
class TestCostCLICommands:
|
||||
"""Test suite for cost tracking CLI commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def setup_test_data(self, temp_db):
|
||||
"""Setup test database with sample cost data."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
|
||||
# Get categories
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
software_cat = cost_manager.get_category_by_name('Software')
|
||||
|
||||
# Create sample cost items
|
||||
cost_items = [
|
||||
CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Test Server',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('25.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=software_cat['id'],
|
||||
name='Test Software',
|
||||
cost_type='one_time',
|
||||
amount_eur=Decimal('50.00'),
|
||||
starting_from_date=date(2025, 1, 15)
|
||||
)
|
||||
]
|
||||
|
||||
for item in cost_items:
|
||||
cost_manager.create_cost_item(item)
|
||||
|
||||
return temp_db
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""Create Click test runner."""
|
||||
return CliRunner()
|
||||
|
||||
def test_cost_report_generate_summary(self, runner, setup_test_data):
|
||||
"""Test cost report generate command with summary format."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', '2025-01',
|
||||
'--format', 'summary',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cost Summary Report - January 2025" in result.output
|
||||
assert "€75.00" in result.output # 25 + 50
|
||||
assert "frontmatter" not in result.output.lower() # Should be properly formatted
|
||||
|
||||
def test_cost_report_generate_detailed(self, runner, setup_test_data):
|
||||
"""Test cost report generate command with detailed format."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', '2025-01',
|
||||
'--format', 'detailed',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Detailed Cost Report - January 2025" in result.output
|
||||
assert "Infrastructure" in result.output
|
||||
assert "Software" in result.output
|
||||
assert "Test Server" in result.output
|
||||
assert "Test Software" in result.output
|
||||
|
||||
def test_cost_report_generate_audit(self, runner, setup_test_data):
|
||||
"""Test cost report generate command with audit format."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', '2025-01',
|
||||
'--format', 'audit',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cost Audit Report - January 2025" in result.output
|
||||
assert "Audit Summary" in result.output
|
||||
assert "Transaction History" in result.output
|
||||
|
||||
def test_cost_report_generate_with_output_file(self, runner, setup_test_data):
|
||||
"""Test saving report to output file."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.md') as f:
|
||||
output_path = f.name
|
||||
|
||||
try:
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', '2025-01',
|
||||
'--output', output_path,
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Report saved to: {output_path}" in result.output
|
||||
|
||||
# Verify file was created
|
||||
assert os.path.exists(output_path)
|
||||
with open(output_path, 'r') as f:
|
||||
content = f.read()
|
||||
assert "Cost Summary Report" in content
|
||||
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
def test_cost_report_generate_invalid_period(self, runner, setup_test_data):
|
||||
"""Test report generation with invalid period format."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', 'invalid-period',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Period must be in YYYY-MM format" in result.output
|
||||
|
||||
def test_cost_report_generate_default_database(self, runner):
|
||||
"""Test report generation with default database path from config."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'generate',
|
||||
'--period', '2025-01'
|
||||
])
|
||||
|
||||
# Should succeed with default config and empty database
|
||||
assert result.exit_code == 0
|
||||
assert "Cost Summary Report - January 2025" in result.output
|
||||
assert "€0.00" in result.output # Empty database shows zero costs
|
||||
|
||||
def test_cost_report_template_show(self, runner):
|
||||
"""Test cost report template show command."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'template', '--show'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Summary Report Template" in result.output
|
||||
assert "Description" in result.output
|
||||
assert "Frontmatter Fields" in result.output
|
||||
|
||||
def test_cost_report_template_different_formats(self, runner):
|
||||
"""Test template show for different formats."""
|
||||
formats = ['summary', 'detailed', 'audit']
|
||||
|
||||
for format_type in formats:
|
||||
result = runner.invoke(cost_commands, [
|
||||
'report', 'template', '--show', '--format', format_type
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"{format_type.title()} Report Template" in result.output
|
||||
|
||||
def test_cost_item_add(self, runner, temp_db):
|
||||
"""Test adding new cost item via CLI."""
|
||||
# Initialize database
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'add', 'Test Item',
|
||||
'--category', 'Infrastructure',
|
||||
'--amount', '15.50',
|
||||
'--type', 'monthly',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created cost item 'Test Item'" in result.output
|
||||
|
||||
# Verify item was created
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
items = cost_manager.list_cost_items()
|
||||
assert len(items) == 1
|
||||
assert items[0]['name'] == 'Test Item'
|
||||
assert float(items[0]['amount_eur']) == 15.50
|
||||
|
||||
def test_cost_item_add_with_description_and_date(self, runner, temp_db):
|
||||
"""Test adding cost item with description and start date."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'add', 'Test Item',
|
||||
'--category', 'Software',
|
||||
'--amount', '99.99',
|
||||
'--type', 'one_time',
|
||||
'--description', 'Test description',
|
||||
'--start-date', '2025-01-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created cost item 'Test Item'" in result.output
|
||||
|
||||
def test_cost_item_add_invalid_category(self, runner, temp_db):
|
||||
"""Test adding item with non-existent category."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'add', 'Test Item',
|
||||
'--category', 'NonExistent',
|
||||
'--amount', '10.00',
|
||||
'--type', 'monthly',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Category 'NonExistent' not found" in result.output
|
||||
assert "Available categories:" in result.output
|
||||
|
||||
def test_cost_item_list(self, runner, setup_test_data):
|
||||
"""Test listing cost items."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'list',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Test Server" in result.output
|
||||
assert "Test Software" in result.output
|
||||
assert "€25.00" in result.output
|
||||
assert "€50.00" in result.output
|
||||
|
||||
def test_cost_item_list_with_filters(self, runner, setup_test_data):
|
||||
"""Test listing cost items with filters."""
|
||||
# Filter by category
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'list',
|
||||
'--category', 'Infrastructure',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Test Server" in result.output
|
||||
assert "Test Software" not in result.output
|
||||
|
||||
# Filter by type
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'list',
|
||||
'--type', 'monthly',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Test Server" in result.output
|
||||
assert "Test Software" not in result.output
|
||||
|
||||
def test_cost_category_list(self, runner, setup_test_data):
|
||||
"""Test listing cost categories."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'category', 'list',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure" in result.output
|
||||
assert "Software" in result.output
|
||||
assert "Total: 8 categories" in result.output # Default categories
|
||||
|
||||
def test_cost_category_add(self, runner, temp_db):
|
||||
"""Test adding new cost category."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'category', 'add', 'Custom Category',
|
||||
'--description', 'Custom test category',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created category 'Custom Category'" in result.output
|
||||
|
||||
# Verify category was created
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
categories = cost_manager.list_categories()
|
||||
category_names = [cat['name'] for cat in categories]
|
||||
assert 'Custom Category' in category_names
|
||||
|
||||
def test_cost_calculate(self, runner, setup_test_data):
|
||||
"""Test cost calculation command."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'calculate',
|
||||
'--period', '2025-01',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cost Calculation - January 2025" in result.output
|
||||
assert "Monthly Recurring: €25.00" in result.output
|
||||
assert "One-time Expenses: €50.00" in result.output
|
||||
assert "Total Period Cost: €75.00" in result.output
|
||||
assert "Active Cost Items: 2" in result.output
|
||||
|
||||
def test_cost_calculate_current_month(self, runner, setup_test_data):
|
||||
"""Test cost calculation for current month (default)."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'calculate',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cost Calculation" in result.output
|
||||
# Should default to current month
|
||||
|
||||
def test_cost_calculate_invalid_period(self, runner, setup_test_data):
|
||||
"""Test cost calculation with invalid period."""
|
||||
result = runner.invoke(cost_commands, [
|
||||
'calculate',
|
||||
'--period', 'invalid',
|
||||
'--database', setup_test_data
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Period must be in YYYY-MM format" in result.output
|
||||
|
||||
def test_cost_item_add_invalid_date_format(self, runner, temp_db):
|
||||
"""Test adding item with invalid date format."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'item', 'add', 'Test Item',
|
||||
'--category', 'Infrastructure',
|
||||
'--amount', '10.00',
|
||||
'--type', 'monthly',
|
||||
'--start-date', 'invalid-date',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Start date must be in YYYY-MM-DD format" in result.output
|
||||
|
||||
def test_help_commands(self, runner):
|
||||
"""Test help output for cost commands."""
|
||||
# Test main cost help
|
||||
result = runner.invoke(cost_commands, ['--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Cost tracking and financial reporting commands" in result.output
|
||||
|
||||
# Test report help
|
||||
result = runner.invoke(cost_commands, ['report', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Generate cost reports" in result.output
|
||||
|
||||
# Test item help
|
||||
result = runner.invoke(cost_commands, ['item', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Manage cost items" in result.output
|
||||
|
||||
# Test category help
|
||||
result = runner.invoke(cost_commands, ['category', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Manage cost categories" in result.output
|
||||
@@ -1,398 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect cost item management system.
|
||||
|
||||
This module tests the complete cost item management functionality including:
|
||||
- Cost item lifecycle (create, update, deactivate)
|
||||
- Category management
|
||||
- Business rule validation
|
||||
- Period-based cost calculations
|
||||
- Integration with database models
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem, CostCategory
|
||||
from markitect.finance.models import FinanceModels
|
||||
|
||||
|
||||
class TestCostItemManager:
|
||||
"""Test suite for cost item management system."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def cost_manager(self, temp_db):
|
||||
"""Create CostItemManager instance with initialized database."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
return CostItemManager(temp_db)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_category_id(self, cost_manager):
|
||||
"""Create a sample category for testing."""
|
||||
return cost_manager.create_category("Test Category", "For testing purposes")
|
||||
|
||||
def test_create_cost_item_valid(self, cost_manager, sample_category_id):
|
||||
"""Test creating a valid cost item."""
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Server",
|
||||
description="Monthly hosting",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('25.50'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
|
||||
cost_item_id = cost_manager.create_cost_item(cost_item)
|
||||
assert cost_item_id is not None
|
||||
|
||||
# Verify item was created
|
||||
retrieved = cost_manager.get_cost_item(cost_item_id)
|
||||
assert retrieved['name'] == "Test Server"
|
||||
assert float(retrieved['amount_eur']) == 25.50
|
||||
assert retrieved['cost_type'] == "monthly"
|
||||
assert retrieved['is_active'] == 1 # SQLite stores booleans as integers
|
||||
|
||||
def test_create_cost_item_validation_errors(self, cost_manager, sample_category_id):
|
||||
"""Test cost item validation errors."""
|
||||
# Missing name
|
||||
with pytest.raises(ValueError, match="name is required"):
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Invalid cost type
|
||||
with pytest.raises(ValueError, match="must be 'monthly' or 'one_time'"):
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Item",
|
||||
cost_type="invalid",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Negative amount
|
||||
with pytest.raises(ValueError, match="must be non-negative"):
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('-10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Invalid date range
|
||||
with pytest.raises(ValueError, match="must be after starting date"):
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 15),
|
||||
ending_date=date(2025, 1, 10)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Inactive without ending date
|
||||
with pytest.raises(ValueError, match="must have an ending date"):
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1),
|
||||
is_active=False
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
def test_update_cost_item(self, cost_manager, sample_category_id):
|
||||
"""Test updating cost item."""
|
||||
# Create initial cost item
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Original Name",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_item_id = cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Update the cost item
|
||||
updates = {
|
||||
'name': 'Updated Name',
|
||||
'amount_eur': Decimal('15.50'),
|
||||
'description': 'Updated description'
|
||||
}
|
||||
success = cost_manager.update_cost_item(cost_item_id, updates)
|
||||
assert success is True
|
||||
|
||||
# Verify updates
|
||||
updated = cost_manager.get_cost_item(cost_item_id)
|
||||
assert updated['name'] == 'Updated Name'
|
||||
assert float(updated['amount_eur']) == 15.50
|
||||
assert updated['description'] == 'Updated description'
|
||||
|
||||
def test_update_nonexistent_cost_item(self, cost_manager):
|
||||
"""Test updating non-existent cost item."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
cost_manager.update_cost_item(99999, {'name': 'New Name'})
|
||||
|
||||
def test_deactivate_cost_item(self, cost_manager, sample_category_id):
|
||||
"""Test deactivating cost item."""
|
||||
# Create cost item
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Test Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_item_id = cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Deactivate with specific ending date
|
||||
ending_date = date(2025, 6, 30)
|
||||
success = cost_manager.deactivate_cost_item(cost_item_id, ending_date)
|
||||
assert success is True
|
||||
|
||||
# Verify deactivation
|
||||
updated = cost_manager.get_cost_item(cost_item_id)
|
||||
assert updated['is_active'] == 0 # SQLite stores False as 0
|
||||
assert updated['ending_date'] == ending_date.isoformat()
|
||||
|
||||
def test_list_cost_items_filtering(self, cost_manager, sample_category_id):
|
||||
"""Test listing cost items with filtering."""
|
||||
# Create multiple cost items
|
||||
items = [
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Monthly Item 1",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="One-time Item",
|
||||
cost_type="one_time",
|
||||
amount_eur=Decimal('50.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Inactive Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('5.00'),
|
||||
starting_from_date=date(2025, 1, 1),
|
||||
ending_date=date(2025, 1, 31),
|
||||
is_active=False
|
||||
)
|
||||
]
|
||||
|
||||
for item in items:
|
||||
cost_manager.create_cost_item(item)
|
||||
|
||||
# Test filtering by active only
|
||||
active_items = cost_manager.list_cost_items(active_only=True)
|
||||
assert len(active_items) == 2
|
||||
assert all(item['is_active'] == 1 for item in active_items)
|
||||
|
||||
# Test filtering by cost type
|
||||
monthly_items = cost_manager.list_cost_items(cost_type="monthly")
|
||||
assert len(monthly_items) == 1 # Only active monthly items
|
||||
assert monthly_items[0]['cost_type'] == "monthly"
|
||||
|
||||
# Test including inactive items
|
||||
all_items = cost_manager.list_cost_items(active_only=False)
|
||||
assert len(all_items) == 3
|
||||
|
||||
def test_get_active_costs_for_period(self, cost_manager, sample_category_id):
|
||||
"""Test retrieving active costs for specific period."""
|
||||
# Create cost items with different date ranges
|
||||
items = [
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Active Throughout",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2024, 12, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Starts Mid-Period",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('15.00'),
|
||||
starting_from_date=date(2025, 1, 15)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Ends Mid-Period",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('20.00'),
|
||||
starting_from_date=date(2024, 12, 1),
|
||||
ending_date=date(2025, 1, 15)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Outside Period",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('25.00'),
|
||||
starting_from_date=date(2025, 2, 1)
|
||||
)
|
||||
]
|
||||
|
||||
for item in items:
|
||||
cost_manager.create_cost_item(item)
|
||||
|
||||
# Get active costs for January 2025
|
||||
period_start = date(2025, 1, 1)
|
||||
period_end = date(2025, 1, 31)
|
||||
active_costs = cost_manager.get_active_costs_for_period(period_start, period_end)
|
||||
|
||||
# Should include first 3 items but not the fourth
|
||||
assert len(active_costs) == 3
|
||||
names = [item['name'] for item in active_costs]
|
||||
assert "Active Throughout" in names
|
||||
assert "Starts Mid-Period" in names
|
||||
assert "Ends Mid-Period" in names
|
||||
assert "Outside Period" not in names
|
||||
|
||||
def test_calculate_period_costs(self, cost_manager, sample_category_id):
|
||||
"""Test period cost calculations."""
|
||||
# Create another category
|
||||
other_category_id = cost_manager.create_category("Other Category")
|
||||
|
||||
# Create cost items in different categories
|
||||
items = [
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Monthly Cost 1",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Monthly Cost 2",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('15.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=other_category_id,
|
||||
name="One-time Cost",
|
||||
cost_type="one_time",
|
||||
amount_eur=Decimal('100.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
]
|
||||
|
||||
for item in items:
|
||||
cost_manager.create_cost_item(item)
|
||||
|
||||
# Calculate costs for January 2025
|
||||
period_start = date(2025, 1, 1)
|
||||
period_end = date(2025, 1, 31)
|
||||
calculations = cost_manager.calculate_period_costs(period_start, period_end)
|
||||
|
||||
assert calculations['total_monthly'] == 25.00
|
||||
assert calculations['total_one_time'] == 100.00
|
||||
assert calculations['total_period'] == 125.00
|
||||
assert calculations['active_cost_items'] == 3
|
||||
|
||||
# Check category breakdown
|
||||
assert 'Test Category' in calculations['category_breakdown']
|
||||
assert 'Other Category' in calculations['category_breakdown']
|
||||
assert calculations['category_breakdown']['Test Category']['monthly'] == 25.00
|
||||
assert calculations['category_breakdown']['Other Category']['one_time'] == 100.00
|
||||
|
||||
def test_category_management(self, cost_manager):
|
||||
"""Test category creation and management."""
|
||||
# Create category with unique name
|
||||
category_id = cost_manager.create_category("Custom Infrastructure", "Custom server costs")
|
||||
assert category_id is not None
|
||||
|
||||
# Retrieve category
|
||||
category = cost_manager.get_category(category_id)
|
||||
assert category['name'] == "Custom Infrastructure"
|
||||
assert category['description'] == "Custom server costs"
|
||||
|
||||
# Test duplicate category
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
cost_manager.create_category("Custom Infrastructure")
|
||||
|
||||
# List categories
|
||||
categories = cost_manager.list_categories()
|
||||
category_names = [cat['name'] for cat in categories]
|
||||
assert "Custom Infrastructure" in category_names
|
||||
# Should also include default categories from schema initialization
|
||||
assert len(categories) >= 9 # 8 default + 1 created
|
||||
|
||||
# Get category by name
|
||||
found_category = cost_manager.get_category_by_name("Custom Infrastructure")
|
||||
assert found_category['id'] == category_id
|
||||
|
||||
def test_cost_item_with_category_validation(self, cost_manager):
|
||||
"""Test cost item creation with category validation."""
|
||||
# Try to create cost item with non-existent category
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
cost_item = CostItem(
|
||||
category_id=99999,
|
||||
name="Test Item",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
def test_precision_handling(self, cost_manager, sample_category_id):
|
||||
"""Test decimal precision in cost calculations."""
|
||||
# Create cost item with precise decimal
|
||||
cost_item = CostItem(
|
||||
category_id=sample_category_id,
|
||||
name="Precise Cost",
|
||||
cost_type="monthly",
|
||||
amount_eur=Decimal('10.99'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_item_id = cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Verify precision is maintained
|
||||
retrieved = cost_manager.get_cost_item(cost_item_id)
|
||||
assert float(retrieved['amount_eur']) == 10.99
|
||||
|
||||
# Test in period calculations
|
||||
calculations = cost_manager.calculate_period_costs(date(2025, 1, 1), date(2025, 1, 31))
|
||||
assert calculations['total_monthly'] == 10.99
|
||||
|
||||
def test_empty_database_operations(self, cost_manager):
|
||||
"""Test operations on empty database."""
|
||||
# List items in empty database
|
||||
items = cost_manager.list_cost_items()
|
||||
assert len(items) == 0
|
||||
|
||||
# Get non-existent item
|
||||
item = cost_manager.get_cost_item(99999)
|
||||
assert item is None
|
||||
|
||||
# Calculate costs for empty period
|
||||
calculations = cost_manager.calculate_period_costs(date(2025, 1, 1), date(2025, 1, 31))
|
||||
assert calculations['total_monthly'] == 0.00
|
||||
assert calculations['total_one_time'] == 0.00
|
||||
assert calculations['active_cost_items'] == 0
|
||||
@@ -1,357 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect cost report template generator.
|
||||
|
||||
This module tests the complete cost report generation functionality including:
|
||||
- Report generation in different formats (summary, detailed, audit)
|
||||
- Markdown output with frontmatter and contentmatter
|
||||
- CLI integration and command functionality
|
||||
- Template structure validation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem
|
||||
from markitect.finance.report_generator import CostReportGenerator, ReportConfig
|
||||
from markitect.finance.models import FinanceModels
|
||||
|
||||
|
||||
class TestCostReportGenerator:
|
||||
"""Test suite for cost report generation system."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def setup_test_data(self, temp_db):
|
||||
"""Setup test database with sample cost data."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
|
||||
# Get categories
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
software_cat = cost_manager.get_category_by_name('Software')
|
||||
|
||||
# Create sample cost items
|
||||
cost_items = [
|
||||
CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Hosteurope Server',
|
||||
description='Monthly server hosting',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=software_cat['id'],
|
||||
name='Bubble.io Plan',
|
||||
description='No-code platform subscription',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('32.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
),
|
||||
CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='SSL Certificate',
|
||||
description='Annual SSL certificate',
|
||||
cost_type='one_time',
|
||||
amount_eur=Decimal('45.00'),
|
||||
starting_from_date=date(2025, 1, 15)
|
||||
)
|
||||
]
|
||||
|
||||
for item in cost_items:
|
||||
cost_manager.create_cost_item(item)
|
||||
|
||||
return temp_db
|
||||
|
||||
@pytest.fixture
|
||||
def report_generator(self, setup_test_data):
|
||||
"""Create report generator with test data."""
|
||||
return CostReportGenerator(setup_test_data)
|
||||
|
||||
def test_report_config_creation(self):
|
||||
"""Test ReportConfig dataclass creation."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
currency="EUR"
|
||||
)
|
||||
|
||||
assert config.format == "summary"
|
||||
assert config.period_start == date(2025, 1, 1)
|
||||
assert config.period_end == date(2025, 1, 31)
|
||||
assert config.currency == "EUR"
|
||||
assert config.include_inactive is False
|
||||
assert config.output_path is None
|
||||
|
||||
def test_generate_summary_report(self, report_generator):
|
||||
"""Test generation of summary cost report."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Check that it's valid markdown with frontmatter
|
||||
assert report.startswith("---")
|
||||
assert "Cost Summary Report - January 2025" in report
|
||||
assert "total_costs: 87.0" in report
|
||||
assert "report_type: \"cost_summary\"" in report
|
||||
|
||||
# Check contentmatter is present
|
||||
assert "contentmatter:" in report
|
||||
assert "cost_data" in report
|
||||
|
||||
# Verify total costs are correct (10 + 32 + 45 = 87)
|
||||
assert "€87.00" in report
|
||||
|
||||
def test_generate_detailed_report(self, report_generator):
|
||||
"""Test generation of detailed cost report."""
|
||||
config = ReportConfig(
|
||||
format="detailed",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Check report structure
|
||||
assert "Detailed Cost Report - January 2025" in report
|
||||
assert "Executive Summary" in report
|
||||
assert "report_type: \"cost_detailed\"" in report
|
||||
|
||||
# Check category sections are present
|
||||
assert "Infrastructure" in report
|
||||
assert "Software" in report
|
||||
|
||||
# Check individual items are listed
|
||||
assert "Hosteurope Server" in report
|
||||
assert "Bubble.io Plan" in report
|
||||
assert "SSL Certificate" in report
|
||||
|
||||
# Check table format
|
||||
assert "| Name | Type | Amount | Status | Start Date |" in report
|
||||
|
||||
def test_generate_audit_report(self, report_generator):
|
||||
"""Test generation of audit trail report."""
|
||||
config = ReportConfig(
|
||||
format="audit",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Check report structure
|
||||
assert "Cost Audit Report - January 2025" in report
|
||||
assert "Audit Summary" in report
|
||||
assert "report_type: \"cost_audit\"" in report
|
||||
assert "audit_trail: True" in report
|
||||
|
||||
# Check audit sections
|
||||
assert "Cost Verification" in report
|
||||
assert "Active Cost Items" in report
|
||||
assert "Transaction History" in report
|
||||
assert "Audit Trail" in report
|
||||
|
||||
# Check contentmatter includes audit data
|
||||
assert "audit_data" in report
|
||||
|
||||
def test_generate_period_report_convenience_method(self, report_generator):
|
||||
"""Test convenience method for generating monthly reports."""
|
||||
report = report_generator.generate_period_report(2025, 1, "summary")
|
||||
|
||||
assert "Cost Summary Report - January 2025" in report
|
||||
assert "2025-01-01" in report
|
||||
assert "2025-01-31" in report
|
||||
|
||||
def test_invalid_report_format_raises_error(self, report_generator):
|
||||
"""Test that invalid report format raises ValueError."""
|
||||
config = ReportConfig(
|
||||
format="invalid",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown report format"):
|
||||
report_generator.generate_report(config)
|
||||
|
||||
def test_frontmatter_structure(self, report_generator):
|
||||
"""Test frontmatter structure in generated reports."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Extract frontmatter (between first two ---)
|
||||
lines = report.split('\n')
|
||||
frontmatter_lines = []
|
||||
in_frontmatter = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip() == "---":
|
||||
if not in_frontmatter:
|
||||
in_frontmatter = True
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if in_frontmatter:
|
||||
frontmatter_lines.append(line)
|
||||
|
||||
frontmatter_text = '\n'.join(frontmatter_lines)
|
||||
|
||||
# Check required frontmatter fields
|
||||
assert 'report_type:' in frontmatter_text
|
||||
assert 'period_start:' in frontmatter_text
|
||||
assert 'period_end:' in frontmatter_text
|
||||
assert 'total_costs:' in frontmatter_text
|
||||
assert 'currency:' in frontmatter_text
|
||||
assert 'generated_at:' in frontmatter_text
|
||||
|
||||
def test_contentmatter_structure(self, report_generator):
|
||||
"""Test contentmatter structure in generated reports."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Extract contentmatter (JSON in HTML comment)
|
||||
assert "<!--" in report
|
||||
assert "contentmatter:" in report
|
||||
assert "-->" in report
|
||||
|
||||
# Find and extract JSON
|
||||
start = report.find("contentmatter:\n") + len("contentmatter:\n")
|
||||
end = report.find("\n-->")
|
||||
json_text = report[start:end].strip()
|
||||
|
||||
# Parse JSON to verify structure
|
||||
contentmatter = json.loads(json_text)
|
||||
|
||||
assert "cost_data" in contentmatter
|
||||
assert "total_monthly" in contentmatter["cost_data"]
|
||||
assert "total_one_time" in contentmatter["cost_data"]
|
||||
assert "categories" in contentmatter["cost_data"]
|
||||
assert "active_items" in contentmatter["cost_data"]
|
||||
|
||||
# Verify totals
|
||||
assert contentmatter["cost_data"]["total_monthly"] == 42.0
|
||||
assert contentmatter["cost_data"]["total_one_time"] == 45.0
|
||||
|
||||
def test_save_report_to_file(self, report_generator, temp_db):
|
||||
"""Test saving report to file."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Save to temporary file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.md') as f:
|
||||
output_path = f.name
|
||||
|
||||
try:
|
||||
report_generator.save_report(report, output_path)
|
||||
|
||||
# Verify file was created and contains expected content
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
saved_content = f.read()
|
||||
|
||||
assert saved_content == report
|
||||
assert "Cost Summary Report" in saved_content
|
||||
|
||||
finally:
|
||||
os.unlink(output_path)
|
||||
|
||||
def test_empty_database_report(self, temp_db):
|
||||
"""Test report generation with empty database."""
|
||||
# Initialize empty database
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
report_generator = CostReportGenerator(temp_db)
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Should still generate valid report with zero costs
|
||||
assert "total_costs: 0.0" in report
|
||||
assert "€0.00" in report
|
||||
|
||||
def test_different_currency(self, report_generator):
|
||||
"""Test report generation with different currency."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
currency="USD"
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
assert 'currency: "USD"' in report
|
||||
# Note: amounts are still in EUR from database, currency is just metadata
|
||||
|
||||
def test_report_with_inactive_items(self, setup_test_data):
|
||||
"""Test report behavior with inactive cost items."""
|
||||
cost_manager = CostItemManager(setup_test_data)
|
||||
|
||||
# Deactivate one item
|
||||
items = cost_manager.list_cost_items()
|
||||
if items:
|
||||
cost_manager.deactivate_cost_item(items[0]['id'], date(2025, 1, 15))
|
||||
|
||||
report_generator = CostReportGenerator(setup_test_data)
|
||||
config = ReportConfig(
|
||||
format="detailed",
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
include_inactive=False
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
# Should still generate valid report, potentially with fewer active items
|
||||
assert "Detailed Cost Report" in report
|
||||
assert "contentmatter:" in report
|
||||
|
||||
def test_cross_month_period(self, report_generator):
|
||||
"""Test report generation across multiple months."""
|
||||
config = ReportConfig(
|
||||
format="summary",
|
||||
period_start=date(2025, 1, 15),
|
||||
period_end=date(2025, 2, 15)
|
||||
)
|
||||
|
||||
report = report_generator.generate_report(config)
|
||||
|
||||
assert "2025-01-15" in report
|
||||
assert "2025-02-15" in report
|
||||
# Should include items active during this period
|
||||
@@ -1,430 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect finance models and database schema.
|
||||
|
||||
This module tests the complete finance schema including:
|
||||
- Database table creation and relationships
|
||||
- Data integrity constraints
|
||||
- Index performance
|
||||
- Schema validation
|
||||
- Migration functionality
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from markitect.finance.models import FinanceModels
|
||||
|
||||
|
||||
class TestFinanceModels:
|
||||
"""Test suite for finance database models."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def finance_models(self, temp_db):
|
||||
"""Create FinanceModels instance with temporary database."""
|
||||
return FinanceModels(temp_db)
|
||||
|
||||
def test_initialize_finance_schema(self, finance_models):
|
||||
"""Test complete finance schema initialization."""
|
||||
# Initialize schema
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
# Validate schema was created
|
||||
assert finance_models.validate_schema()
|
||||
|
||||
# Check all required tables exist
|
||||
schema_info = finance_models.get_schema_info()
|
||||
expected_tables = [
|
||||
'cost_categories',
|
||||
'cost_items',
|
||||
'cost_periods',
|
||||
'cost_transactions',
|
||||
'issue_cost_allocations',
|
||||
'issue_activity_log'
|
||||
]
|
||||
|
||||
for table in expected_tables:
|
||||
assert table in schema_info['tables']
|
||||
assert len(schema_info['tables'][table]['columns']) > 0
|
||||
|
||||
def test_cost_categories_table(self, finance_models):
|
||||
"""Test cost categories table structure and data."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test default categories were inserted
|
||||
cursor.execute('SELECT COUNT(*) FROM cost_categories')
|
||||
count = cursor.fetchone()[0]
|
||||
assert count >= 8 # At least 8 default categories
|
||||
|
||||
# Test unique constraint
|
||||
with pytest.raises(Exception): # Should violate unique constraint
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_categories (name, description)
|
||||
VALUES ('Infrastructure', 'Duplicate category')
|
||||
''')
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_cost_items_table(self, finance_models):
|
||||
"""Test cost items table constraints and relationships."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Insert test category
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_categories (name, description)
|
||||
VALUES ('Test Category', 'For testing')
|
||||
''')
|
||||
category_id = cursor.lastrowid
|
||||
|
||||
# Test valid cost item insertion
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, 'Test Server', 'monthly', 10.50, '2025-01-01')
|
||||
''', (category_id,))
|
||||
|
||||
# Test cost_type constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, 'Invalid Type', 'invalid', 10.00, '2025-01-01')
|
||||
''', (category_id,))
|
||||
|
||||
# Test negative amount constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, 'Negative Cost', 'monthly', -10.00, '2025-01-01')
|
||||
''', (category_id,))
|
||||
|
||||
# Test date range constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date, ending_date)
|
||||
VALUES (?, 'Invalid Dates', 'monthly', 10.00, '2025-01-01', '2024-12-31')
|
||||
''', (category_id,))
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_cost_periods_table(self, finance_models):
|
||||
"""Test cost periods table constraints."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test valid period insertion
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end)
|
||||
VALUES ('2025-01-01', '2025-01-31')
|
||||
''')
|
||||
|
||||
# Test period date constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end)
|
||||
VALUES ('2025-01-31', '2025-01-01')
|
||||
''')
|
||||
|
||||
# Test status constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end, status)
|
||||
VALUES ('2025-02-01', '2025-02-28', 'invalid_status')
|
||||
''')
|
||||
|
||||
# Test unique period constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end)
|
||||
VALUES ('2025-01-01', '2025-01-31')
|
||||
''')
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_cost_transactions_table(self, finance_models):
|
||||
"""Test cost transactions table and audit trail."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create test data
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_categories (name) VALUES ('Test Category')
|
||||
''')
|
||||
category_id = cursor.lastrowid
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, 'Test Item', 'monthly', 10.00, '2025-01-01')
|
||||
''', (category_id,))
|
||||
cost_item_id = cursor.lastrowid
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end)
|
||||
VALUES ('2025-01-01', '2025-01-31')
|
||||
''')
|
||||
period_id = cursor.lastrowid
|
||||
|
||||
# Test valid transaction
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, cost_item_id, transaction_type, amount_eur, transaction_date)
|
||||
VALUES (?, ?, 'cost_incurred', 10.00, '2025-01-15')
|
||||
''', (period_id, cost_item_id))
|
||||
|
||||
# Test transaction type constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, cost_item_id, transaction_type, amount_eur, transaction_date)
|
||||
VALUES (?, ?, 'invalid_type', 10.00, '2025-01-15')
|
||||
''', (period_id, cost_item_id))
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_issue_cost_allocations_table(self, finance_models):
|
||||
"""Test issue cost allocations table."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create test period
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_periods (period_start, period_end)
|
||||
VALUES ('2025-01-01', '2025-01-31')
|
||||
''')
|
||||
period_id = cursor.lastrowid
|
||||
|
||||
# Test valid allocation
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_cost_allocations
|
||||
(issue_id, period_id, allocated_amount, allocation_date)
|
||||
VALUES (123, ?, 5.50, '2025-01-31')
|
||||
''', (period_id,))
|
||||
|
||||
# Test positive amount constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_cost_allocations
|
||||
(issue_id, period_id, allocated_amount, allocation_date)
|
||||
VALUES (124, ?, -1.00, '2025-01-31')
|
||||
''', (period_id,))
|
||||
|
||||
# Test unique issue-period constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_cost_allocations
|
||||
(issue_id, period_id, allocated_amount, allocation_date)
|
||||
VALUES (123, ?, 3.00, '2025-01-31')
|
||||
''', (period_id,))
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_issue_activity_log_table(self, finance_models):
|
||||
"""Test issue activity log table."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test valid activity log entry
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_activity_log
|
||||
(issue_id, activity_type, activity_date)
|
||||
VALUES (123, 'created', '2025-01-15')
|
||||
''')
|
||||
|
||||
# Test activity type constraint
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_activity_log
|
||||
(issue_id, activity_type, activity_date)
|
||||
VALUES (124, 'invalid_activity', '2025-01-15')
|
||||
''')
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_foreign_key_constraints(self, finance_models):
|
||||
"""Test foreign key relationships are enforced."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test cost_items references cost_categories
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (999, 'Invalid Category', 'monthly', 10.00, '2025-01-01')
|
||||
''')
|
||||
|
||||
# Test cost_transactions references cost_periods
|
||||
with pytest.raises(Exception):
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, transaction_type, amount_eur, transaction_date)
|
||||
VALUES (999, 'cost_incurred', 10.00, '2025-01-15')
|
||||
''')
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_indexes_created(self, finance_models):
|
||||
"""Test that performance indexes are created."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
schema_info = finance_models.get_schema_info()
|
||||
index_names = [idx['name'] for idx in schema_info['indexes']]
|
||||
|
||||
# Check critical indexes exist
|
||||
expected_indexes = [
|
||||
'idx_cost_items_active',
|
||||
'idx_cost_items_type',
|
||||
'idx_cost_periods_status',
|
||||
'idx_cost_transactions_period',
|
||||
'idx_issue_allocations_issue'
|
||||
]
|
||||
|
||||
for index in expected_indexes:
|
||||
assert index in index_names
|
||||
|
||||
def test_schema_validation(self, finance_models):
|
||||
"""Test schema validation functionality."""
|
||||
# Before initialization
|
||||
assert not finance_models.validate_schema()
|
||||
|
||||
# After initialization
|
||||
finance_models.initialize_finance_schema()
|
||||
assert finance_models.validate_schema()
|
||||
|
||||
def test_drop_finance_schema(self, finance_models):
|
||||
"""Test schema cleanup functionality."""
|
||||
# Initialize schema
|
||||
finance_models.initialize_finance_schema()
|
||||
assert finance_models.validate_schema()
|
||||
|
||||
# Drop schema
|
||||
finance_models.drop_finance_schema()
|
||||
assert not finance_models.validate_schema()
|
||||
|
||||
def test_database_integration(self, temp_db):
|
||||
"""Test integration with existing DatabaseManager."""
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
# Initialize standard database
|
||||
db_manager = DatabaseManager(temp_db)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Verify finance tables were also created
|
||||
finance_models = FinanceModels(temp_db)
|
||||
assert finance_models.validate_schema()
|
||||
|
||||
# Verify existing tables still exist
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name IN ('markdown_files', 'schemas')
|
||||
''')
|
||||
existing_tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
assert 'markdown_files' in existing_tables
|
||||
assert 'schemas' in existing_tables
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_decimal_precision(self, finance_models):
|
||||
"""Test decimal precision for financial calculations."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Insert test category
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_categories (name) VALUES ('Test Category')
|
||||
''')
|
||||
category_id = cursor.lastrowid
|
||||
|
||||
# Test precise decimal amounts
|
||||
test_amounts = [10.50, 99.99, 0.01, 1234.56]
|
||||
|
||||
for amount in test_amounts:
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, ?, 'monthly', ?, '2025-01-01')
|
||||
''', (category_id, f'Test Item {amount}', amount))
|
||||
|
||||
# Verify precision is maintained
|
||||
cursor.execute('SELECT amount_eur FROM cost_items ORDER BY id')
|
||||
stored_amounts = [float(row[0]) for row in cursor.fetchall()]
|
||||
|
||||
assert stored_amounts == test_amounts
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_example_cost_data(self, finance_models):
|
||||
"""Test insertion of example cost data from issue description."""
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
conn = finance_models.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get category IDs
|
||||
cursor.execute('SELECT id, name FROM cost_categories')
|
||||
categories = {name: id for id, name in cursor.fetchall()}
|
||||
|
||||
# Insert example costs from issue #88
|
||||
example_costs = [
|
||||
('Infrastructure', 'Hosteurope Server', 'Monthly server hosting', 10.00),
|
||||
('Software', 'Bubble.io Plan', 'No-code platform subscription', 32.00),
|
||||
('Domain & DNS', 'Coulomb.social Domain', 'Domain registration', 5.00),
|
||||
('Development Tools', 'Claude Code Plan', 'AI coding assistant', 20.00),
|
||||
('AI & ML Services', 'Gemini Plan', 'LLM API for specifications', 20.00)
|
||||
]
|
||||
|
||||
for category_name, name, description, amount in example_costs:
|
||||
category_id = categories.get(category_name)
|
||||
assert category_id is not None
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_items
|
||||
(category_id, name, description, cost_type, amount_eur, starting_from_date)
|
||||
VALUES (?, ?, ?, 'monthly', ?, '2025-01-01')
|
||||
''', (category_id, name, description, amount))
|
||||
|
||||
# Verify total monthly costs
|
||||
cursor.execute('''
|
||||
SELECT SUM(amount_eur) FROM cost_items
|
||||
WHERE cost_type = 'monthly' AND is_active = TRUE
|
||||
''')
|
||||
total_monthly = float(cursor.fetchone()[0])
|
||||
|
||||
assert total_monthly == 87.00 # €87/month as described in issue
|
||||
|
||||
conn.close()
|
||||
@@ -1,797 +0,0 @@
|
||||
"""
|
||||
Comprehensive tests for GraphQL mutations (Issue #10).
|
||||
|
||||
Tests all aspects of the GraphQL write interface including:
|
||||
- Mutation schema validation
|
||||
- Markdown file CRUD operations
|
||||
- Schema CRUD operations
|
||||
- Error handling
|
||||
- CLI integration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from markitect.graphql.schema import schema
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_db_path():
|
||||
"""Create temporary database with some test data."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add sample data
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Sample markdown file
|
||||
cursor.execute("""
|
||||
INSERT INTO markdown_files (filename, content, front_matter, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
'existing.md',
|
||||
'# Existing Document\n\nThis document already exists.',
|
||||
'{"title": "Existing Document"}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
# Sample schema
|
||||
cursor.execute("""
|
||||
INSERT INTO schemas (filename, title, description, schema_content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
'existing-schema.json',
|
||||
'Existing Schema',
|
||||
'A schema that already exists',
|
||||
'{"type": "object", "properties": {"name": {"type": "string"}}}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
class TestGraphQLMutationSchema:
|
||||
"""Test GraphQL mutation schema definition and validation."""
|
||||
|
||||
def test_schema_has_mutations(self):
|
||||
"""Test that the GraphQL schema has mutations."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
mutationType {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
assert result.errors is None
|
||||
mutation_type = result.data['__schema']['mutationType']
|
||||
assert mutation_type is not None
|
||||
assert mutation_type['name'] == 'Mutation'
|
||||
|
||||
field_names = [field['name'] for field in mutation_type['fields']]
|
||||
assert 'addMarkdownFile' in field_names
|
||||
assert 'updateMarkdownFile' in field_names
|
||||
assert 'addSchema' in field_names
|
||||
assert 'updateSchema' in field_names
|
||||
assert 'deleteSchema' in field_names
|
||||
|
||||
def test_add_markdown_file_mutation_signature(self):
|
||||
"""Test addMarkdownFile mutation has correct signature."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
mutationType {
|
||||
fields {
|
||||
name
|
||||
args {
|
||||
name
|
||||
type {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
mutation_fields = result.data['__schema']['mutationType']['fields']
|
||||
add_file_field = next(f for f in mutation_fields if f['name'] == 'addMarkdownFile')
|
||||
|
||||
arg_names = [arg['name'] for arg in add_file_field['args']]
|
||||
assert 'filename' in arg_names
|
||||
assert 'content' in arg_names
|
||||
|
||||
def test_mutation_payload_types(self):
|
||||
"""Test that mutation payload types have correct structure."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
types {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
type {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
types = {t['name']: t for t in result.data['__schema']['types']}
|
||||
|
||||
# Check AddMarkdownFilePayload
|
||||
payload = types.get('AddMarkdownFilePayload')
|
||||
assert payload is not None
|
||||
field_names = [f['name'] for f in payload['fields']]
|
||||
assert 'markdownFile' in field_names
|
||||
assert 'success' in field_names
|
||||
assert 'errors' in field_names
|
||||
|
||||
|
||||
class TestMarkdownFileMutations:
|
||||
"""Test markdown file CRUD mutations."""
|
||||
|
||||
def test_add_markdown_file_success(self, temp_db_path):
|
||||
"""Test successful markdown file creation."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "new-file.md"
|
||||
content: "# New File\\n\\nThis is new content."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert data['markdownFile'] is not None
|
||||
assert data['markdownFile']['filename'] == 'new-file.md'
|
||||
assert 'New File' in data['markdownFile']['content']
|
||||
assert data['markdownFile']['wordCount'] > 0
|
||||
|
||||
def test_add_markdown_file_with_front_matter(self, temp_db_path):
|
||||
"""Test markdown file creation with front matter."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
content_with_frontmatter = '''---
|
||||
title: "Test Document"
|
||||
author: "Test Author"
|
||||
tags: ["test", "markdown"]
|
||||
---
|
||||
|
||||
# Test Document
|
||||
|
||||
This is a test document with front matter.
|
||||
'''
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "with-frontmatter.md"
|
||||
content: "%s"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
hasFrontMatter
|
||||
frontMatter {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % content_with_frontmatter.replace('\n', '\\n').replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert data['markdownFile']['hasFrontMatter'] is True
|
||||
front_matter_keys = [fm['key'] for fm in data['markdownFile']['frontMatter']]
|
||||
assert 'title' in front_matter_keys
|
||||
assert 'author' in front_matter_keys
|
||||
|
||||
def test_add_markdown_file_duplicate_filename(self, populated_db_path):
|
||||
"""Test adding a file with duplicate filename (should succeed as update)."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "existing.md"
|
||||
content: "# Updated Content\\n\\nThis content replaces the existing."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
filename
|
||||
content
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert 'Updated Content' in data['markdownFile']['content']
|
||||
|
||||
def test_update_markdown_file_success(self, populated_db_path):
|
||||
"""Test successful markdown file update."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: 1
|
||||
content: "# Updated Title\\n\\nThis content has been updated."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
content
|
||||
wordCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert 'Updated Title' in data['markdownFile']['content']
|
||||
|
||||
def test_update_markdown_file_not_found(self, temp_db_path):
|
||||
"""Test updating non-existent markdown file."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: 999
|
||||
content: "# This should fail"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert data['markdownFile'] is None
|
||||
assert len(data['errors']) > 0
|
||||
assert 'not found' in data['errors'][0].lower()
|
||||
|
||||
def test_update_markdown_file_no_content(self, populated_db_path):
|
||||
"""Test updating markdown file without providing content."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(id: 1) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert 'required' in data['errors'][0].lower()
|
||||
|
||||
|
||||
class TestSchemaMutations:
|
||||
"""Test JSON schema CRUD mutations."""
|
||||
|
||||
def test_add_schema_success(self, temp_db_path):
|
||||
"""Test successful schema creation."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
schema_content = {
|
||||
"type": "object",
|
||||
"title": "User Schema",
|
||||
"description": "Schema for user objects",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "user-schema.json"
|
||||
schemaContent: "%s"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
filename
|
||||
title
|
||||
description
|
||||
propertyCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % json.dumps(schema_content).replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addSchema']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert data['schema']['filename'] == 'user-schema.json'
|
||||
assert data['schema']['title'] == 'User Schema'
|
||||
assert data['schema']['propertyCount'] == 2
|
||||
|
||||
def test_add_schema_invalid_json(self, temp_db_path):
|
||||
"""Test adding schema with invalid JSON."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "invalid-schema.json"
|
||||
schemaContent: "{ invalid json }"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# GraphQL should reject invalid JSON at the schema validation level
|
||||
assert result.errors is not None
|
||||
assert len(result.errors) > 0
|
||||
assert "Badly formed JSONString" in str(result.errors[0])
|
||||
|
||||
def test_update_schema_success(self, populated_db_path):
|
||||
"""Test successful schema update."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
new_schema = {
|
||||
"type": "object",
|
||||
"title": "Updated Schema",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"}
|
||||
}
|
||||
}
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: 1
|
||||
schemaContent: "%s"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
title
|
||||
propertyCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % json.dumps(new_schema).replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateSchema']
|
||||
assert data['success'] is True
|
||||
assert data['schema']['title'] == 'Updated Schema'
|
||||
assert data['schema']['propertyCount'] == 2
|
||||
|
||||
def test_update_schema_not_found(self, temp_db_path):
|
||||
"""Test updating non-existent schema."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: 999
|
||||
schemaContent: "{\\"type\\": \\"object\\"}"
|
||||
) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateSchema']
|
||||
assert data['success'] is False
|
||||
assert 'not found' in data['errors'][0].lower()
|
||||
|
||||
def test_delete_schema_success(self, populated_db_path):
|
||||
"""Test successful schema deletion."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "existing-schema.json") {
|
||||
success
|
||||
deletedFilename
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['deleteSchema']
|
||||
assert data['success'] is True
|
||||
assert data['deletedFilename'] == 'existing-schema.json'
|
||||
assert len(data['errors']) == 0
|
||||
|
||||
def test_delete_schema_not_found(self, temp_db_path):
|
||||
"""Test deleting non-existent schema."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "nonexistent.json") {
|
||||
success
|
||||
deletedFilename
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['deleteSchema']
|
||||
assert data['success'] is False
|
||||
assert data['deletedFilename'] is None
|
||||
|
||||
|
||||
class TestMutationErrorHandling:
|
||||
"""Test error handling in mutations."""
|
||||
|
||||
def test_database_error_handling(self, temp_db_path):
|
||||
"""Test mutation behavior when database is unavailable."""
|
||||
# Use a non-existent database path
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "test.md"
|
||||
content: "# Test"
|
||||
) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert len(data['errors']) > 0
|
||||
|
||||
def test_invalid_mutation_syntax(self):
|
||||
"""Test handling of invalid mutation syntax."""
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(filename: "test.md") {
|
||||
success
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# Should have errors due to missing required 'content' argument
|
||||
assert result.errors is not None
|
||||
|
||||
def test_missing_required_arguments(self):
|
||||
"""Test mutations with missing required arguments."""
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(filename: "test.json") {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# Should have errors due to missing required 'schemaContent' argument
|
||||
assert result.errors is not None
|
||||
|
||||
|
||||
class TestMutationIntegration:
|
||||
"""Test full integration of mutations with database."""
|
||||
|
||||
def test_crud_workflow(self, temp_db_path):
|
||||
"""Test complete CRUD workflow for markdown files."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
# 1. Create a file
|
||||
create_mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "workflow-test.md"
|
||||
content: "# Original Content\\n\\nOriginal text."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(create_mutation)
|
||||
assert result.data['addMarkdownFile']['success'] is True
|
||||
file_id = result.data['addMarkdownFile']['markdownFile']['id']
|
||||
|
||||
# 2. Update the file
|
||||
update_mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: %d
|
||||
content: "# Updated Content\\n\\nUpdated text."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
''' % file_id
|
||||
|
||||
result = schema.execute(update_mutation)
|
||||
assert result.data['updateMarkdownFile']['success'] is True
|
||||
assert 'Updated Content' in result.data['updateMarkdownFile']['markdownFile']['content']
|
||||
|
||||
def test_schema_crud_workflow(self, temp_db_path):
|
||||
"""Test complete CRUD workflow for schemas."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
# 1. Create a schema
|
||||
create_mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "workflow-schema.json"
|
||||
schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Original\\"}"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(create_mutation)
|
||||
assert result.data['addSchema']['success'] is True
|
||||
schema_id = result.data['addSchema']['schema']['id']
|
||||
|
||||
# 2. Update the schema
|
||||
update_mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: %d
|
||||
schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Updated\\"}"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
''' % schema_id
|
||||
|
||||
result = schema.execute(update_mutation)
|
||||
assert result.data['updateSchema']['success'] is True
|
||||
assert result.data['updateSchema']['schema']['title'] == 'Updated'
|
||||
|
||||
# 3. Delete the schema
|
||||
delete_mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "workflow-schema.json") {
|
||||
success
|
||||
deletedFilename
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(delete_mutation)
|
||||
assert result.data['deleteSchema']['success'] is True
|
||||
assert result.data['deleteSchema']['deletedFilename'] == 'workflow-schema.json'
|
||||
|
||||
|
||||
class TestMutationCLI:
|
||||
"""Test CLI integration for mutations."""
|
||||
|
||||
def test_graphql_mutate_command_available(self):
|
||||
"""Test that graphql-mutate command is available."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-mutate", "--help"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Execute GraphQL mutations" in result.stdout
|
||||
assert "--local" in result.stdout
|
||||
assert "--variables" in result.stdout
|
||||
|
||||
def test_mutation_examples_in_help(self):
|
||||
"""Test that mutation examples are included in help."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-examples"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Mutation Examples" in result.stdout
|
||||
assert "addMarkdownFile" in result.stdout
|
||||
assert "updateMarkdownFile" in result.stdout
|
||||
assert "addSchema" in result.stdout
|
||||
assert "deleteSchema" in result.stdout
|
||||
|
||||
|
||||
class TestMutationPayloads:
|
||||
"""Test mutation payload structures."""
|
||||
|
||||
def test_add_markdown_file_payload_structure(self, temp_db_path):
|
||||
"""Test AddMarkdownFilePayload has correct structure."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "payload-test.md"
|
||||
content: "# Payload Test"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
lineCount
|
||||
hasFrontMatter
|
||||
createdAt
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
payload = result.data['addMarkdownFile']
|
||||
|
||||
# Check payload structure
|
||||
assert isinstance(payload['success'], bool)
|
||||
assert isinstance(payload['errors'], list)
|
||||
|
||||
if payload['success']:
|
||||
md_file = payload['markdownFile']
|
||||
assert md_file is not None
|
||||
assert isinstance(md_file['id'], int)
|
||||
assert isinstance(md_file['filename'], str)
|
||||
assert isinstance(md_file['wordCount'], int)
|
||||
assert isinstance(md_file['lineCount'], int)
|
||||
assert isinstance(md_file['hasFrontMatter'], bool)
|
||||
|
||||
def test_error_payload_structure(self, temp_db_path):
|
||||
"""Test error payloads have correct structure."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "error-test.md"
|
||||
content: "# Error Test"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
payload = result.data['addMarkdownFile']
|
||||
|
||||
assert payload['success'] is False
|
||||
assert payload['markdownFile'] is None
|
||||
assert isinstance(payload['errors'], list)
|
||||
assert len(payload['errors']) > 0
|
||||
assert all(isinstance(error, str) for error in payload['errors'])
|
||||
@@ -1,794 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #122 - Daily worktime estimation and distribution of associated cost
|
||||
|
||||
This module contains comprehensive tests for the worktime tracking system
|
||||
that estimates daily work time and distributes costs proportionally based
|
||||
on time allocation across issues.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sqlite3
|
||||
from datetime import datetime, date, timedelta
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from markitect.finance.worktime_tracker import WorktimeTracker, WorktimeEntry, DailySummary
|
||||
from markitect.finance.worktime_commands import worktime, _parse_duration, _format_duration
|
||||
|
||||
|
||||
class TestWorktimeEntry:
|
||||
"""Test suite for WorktimeEntry dataclass."""
|
||||
|
||||
def test_worktime_entry_creation(self):
|
||||
"""Test that WorktimeEntry objects can be created properly."""
|
||||
entry = WorktimeEntry(
|
||||
id=1,
|
||||
issue_id=122,
|
||||
work_date=date.today(),
|
||||
duration_minutes=90,
|
||||
description="Working on worktime tracking"
|
||||
)
|
||||
|
||||
assert entry.id == 1
|
||||
assert entry.issue_id == 122
|
||||
assert entry.work_date == date.today()
|
||||
assert entry.duration_minutes == 90
|
||||
assert entry.description == "Working on worktime tracking"
|
||||
|
||||
def test_worktime_entry_defaults(self):
|
||||
"""Test that WorktimeEntry has proper default values."""
|
||||
entry = WorktimeEntry()
|
||||
|
||||
assert entry.id is None
|
||||
assert entry.issue_id is None
|
||||
assert entry.work_date is None
|
||||
assert entry.start_time is None
|
||||
assert entry.end_time is None
|
||||
assert entry.duration_minutes is None
|
||||
assert entry.description is None
|
||||
assert entry.entry_type == "manual"
|
||||
assert entry.created_at is None
|
||||
assert entry.updated_at is None
|
||||
|
||||
|
||||
class TestDailySummary:
|
||||
"""Test suite for DailySummary dataclass."""
|
||||
|
||||
def test_daily_summary_creation(self):
|
||||
"""Test that DailySummary objects can be created properly."""
|
||||
entries = [
|
||||
WorktimeEntry(id=1, issue_id=122, duration_minutes=90),
|
||||
WorktimeEntry(id=2, issue_id=123, duration_minutes=60)
|
||||
]
|
||||
|
||||
summary = DailySummary(
|
||||
work_date=date.today(),
|
||||
total_minutes=150,
|
||||
issue_count=2,
|
||||
entries=entries,
|
||||
cost_per_minute=Decimal('0.1'),
|
||||
total_cost_allocated=Decimal('15.0')
|
||||
)
|
||||
|
||||
assert summary.work_date == date.today()
|
||||
assert summary.total_minutes == 150
|
||||
assert summary.issue_count == 2
|
||||
assert len(summary.entries) == 2
|
||||
assert summary.cost_per_minute == Decimal('0.1')
|
||||
assert summary.total_cost_allocated == Decimal('15.0')
|
||||
|
||||
|
||||
class TestWorktimeTracker:
|
||||
"""Test suite for WorktimeTracker service."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures with temporary database."""
|
||||
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
||||
self.temp_db.close()
|
||||
self.db_path = self.temp_db.name
|
||||
self.tracker = WorktimeTracker(self.db_path)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test fixtures."""
|
||||
Path(self.db_path).unlink(missing_ok=True)
|
||||
|
||||
def test_tracker_initialization(self):
|
||||
"""Test that tracker initializes properly with database."""
|
||||
assert self.tracker.db_path == self.db_path
|
||||
assert self.tracker.finance_models is not None
|
||||
|
||||
# Verify worktime tables were created
|
||||
with self.tracker.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
expected_tables = ['worktime_entries', 'daily_worktime_summaries', 'worktime_cost_distributions']
|
||||
for table in expected_tables:
|
||||
assert table in tables
|
||||
|
||||
def test_log_worktime_basic(self):
|
||||
"""Test logging basic worktime entry."""
|
||||
entry_id = self.tracker.log_worktime(
|
||||
issue_id=122,
|
||||
duration_minutes=90,
|
||||
description="Implementing worktime tracking"
|
||||
)
|
||||
|
||||
assert entry_id is not None
|
||||
|
||||
# Verify entry was stored
|
||||
entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].issue_id == 122
|
||||
assert entries[0].duration_minutes == 90
|
||||
assert entries[0].description == "Implementing worktime tracking"
|
||||
|
||||
def test_log_worktime_with_timestamps(self):
|
||||
"""Test logging worktime with start and end times."""
|
||||
now = datetime.now()
|
||||
start_time = now.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
end_time = now.replace(hour=10, minute=30, second=0, microsecond=0)
|
||||
|
||||
entry_id = self.tracker.log_worktime(
|
||||
issue_id=122,
|
||||
duration_minutes=90,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
description="Morning work session"
|
||||
)
|
||||
|
||||
entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].start_time.hour == 9
|
||||
assert entries[0].end_time.hour == 10
|
||||
assert entries[0].end_time.minute == 30
|
||||
|
||||
def test_log_worktime_validation(self):
|
||||
"""Test worktime logging validation."""
|
||||
# Test negative duration
|
||||
with pytest.raises(ValueError, match="Duration must be positive"):
|
||||
self.tracker.log_worktime(issue_id=122, duration_minutes=-30)
|
||||
|
||||
# Test zero duration
|
||||
with pytest.raises(ValueError, match="Duration must be positive"):
|
||||
self.tracker.log_worktime(issue_id=122, duration_minutes=0)
|
||||
|
||||
def test_get_worktime_entries_filtering(self):
|
||||
"""Test worktime entry retrieval with various filters."""
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
# Create test entries
|
||||
self.tracker.log_worktime(122, 60, work_date=today, description="Today's work")
|
||||
self.tracker.log_worktime(123, 90, work_date=today, description="Today's other work")
|
||||
self.tracker.log_worktime(122, 45, work_date=yesterday, description="Yesterday's work")
|
||||
|
||||
# Test filtering by issue
|
||||
issue_122_entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(issue_122_entries) == 2
|
||||
assert all(e.issue_id == 122 for e in issue_122_entries)
|
||||
|
||||
# Test filtering by date
|
||||
today_entries = self.tracker.get_worktime_entries(work_date=today)
|
||||
assert len(today_entries) == 2
|
||||
assert all(e.work_date == today for e in today_entries)
|
||||
|
||||
# Test date range filtering
|
||||
range_entries = self.tracker.get_worktime_entries(start_date=yesterday, end_date=today)
|
||||
assert len(range_entries) == 3
|
||||
|
||||
def test_get_daily_summary(self):
|
||||
"""Test daily worktime summary generation."""
|
||||
today = date.today()
|
||||
|
||||
# Log multiple entries for today
|
||||
self.tracker.log_worktime(122, 90, work_date=today)
|
||||
self.tracker.log_worktime(123, 60, work_date=today)
|
||||
self.tracker.log_worktime(122, 30, work_date=today) # Second entry for same issue
|
||||
|
||||
summary = self.tracker.get_daily_summary(today)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.work_date == today
|
||||
assert summary.total_minutes == 180 # 90 + 60 + 30
|
||||
assert summary.issue_count == 2 # Issues 122 and 123
|
||||
assert len(summary.entries) == 3
|
||||
|
||||
def test_estimate_daily_worktime_equal_distribution(self):
|
||||
"""Test daily worktime estimation with equal distribution."""
|
||||
today = date.today()
|
||||
issues = [122, 123, 124]
|
||||
|
||||
result = self.tracker.estimate_daily_worktime(
|
||||
work_date=today,
|
||||
total_hours=6.0,
|
||||
issues=issues,
|
||||
distribution_method="equal"
|
||||
)
|
||||
|
||||
assert result['work_date'] == today
|
||||
assert result['total_minutes'] == 360 # 6 hours
|
||||
assert result['distribution_method'] == "equal"
|
||||
assert result['issues_count'] == 3
|
||||
|
||||
# Each issue should get 120 minutes (2 hours)
|
||||
for issue_id in issues:
|
||||
assert result['issue_estimates'][issue_id] == 120
|
||||
|
||||
# Verify entries were created
|
||||
entries = self.tracker.get_worktime_entries(work_date=today)
|
||||
assert len(entries) == 3
|
||||
assert all(e.entry_type == "estimated" for e in entries)
|
||||
|
||||
def test_estimate_daily_worktime_activity_based(self):
|
||||
"""Test daily worktime estimation with activity-based distribution."""
|
||||
today = date.today()
|
||||
|
||||
# Mock activity data - issue 122 has more activities
|
||||
with patch.object(self.tracker, '_get_activity_weights_for_date') as mock_weights:
|
||||
mock_weights.return_value = {122: 5, 123: 2, 124: 1} # Different activity levels
|
||||
|
||||
result = self.tracker.estimate_daily_worktime(
|
||||
work_date=today,
|
||||
total_hours=8.0,
|
||||
issues=[122, 123, 124],
|
||||
distribution_method="activity_based"
|
||||
)
|
||||
|
||||
# Verify distribution is proportional to activities
|
||||
total_weight = 5 + 2 + 1 # 8
|
||||
expected_122 = int((5/8) * 480) # 300 minutes
|
||||
expected_123 = int((2/8) * 480) # 120 minutes
|
||||
expected_124 = int((1/8) * 480) # 60 minutes
|
||||
|
||||
assert result['issue_estimates'][122] == expected_122
|
||||
assert result['issue_estimates'][123] == expected_123
|
||||
assert result['issue_estimates'][124] == expected_124
|
||||
|
||||
def test_distribute_daily_costs(self):
|
||||
"""Test daily cost distribution based on time allocation."""
|
||||
today = date.today()
|
||||
|
||||
# Log different amounts of time for different issues
|
||||
self.tracker.log_worktime(122, 120, work_date=today) # 2 hours
|
||||
self.tracker.log_worktime(123, 60, work_date=today) # 1 hour
|
||||
self.tracker.log_worktime(124, 120, work_date=today) # 2 hours
|
||||
# Total: 5 hours (300 minutes)
|
||||
|
||||
total_cost = Decimal('150.00') # €150 for the day
|
||||
result = self.tracker.distribute_daily_costs(
|
||||
work_date=today,
|
||||
total_daily_cost=total_cost
|
||||
)
|
||||
|
||||
assert result['work_date'] == today
|
||||
assert result['total_cost'] == 150.0
|
||||
assert result['total_minutes'] == 300
|
||||
assert result['cost_per_minute'] == 0.5 # €150 / 300 minutes
|
||||
|
||||
# Check cost distribution
|
||||
assert result['distributions'][122]['cost_allocated'] == 60.0 # 120 min * €0.5
|
||||
assert result['distributions'][123]['cost_allocated'] == 30.0 # 60 min * €0.5
|
||||
assert result['distributions'][124]['cost_allocated'] == 60.0 # 120 min * €0.5
|
||||
|
||||
# Check percentages
|
||||
assert result['distributions'][122]['percentage'] == 40.0 # 120/300 * 100
|
||||
assert result['distributions'][123]['percentage'] == 20.0 # 60/300 * 100
|
||||
assert result['distributions'][124]['percentage'] == 40.0 # 120/300 * 100
|
||||
|
||||
def test_distribute_daily_costs_no_worktime(self):
|
||||
"""Test cost distribution when no worktime is logged."""
|
||||
today = date.today()
|
||||
total_cost = Decimal('100.00')
|
||||
|
||||
result = self.tracker.distribute_daily_costs(
|
||||
work_date=today,
|
||||
total_daily_cost=total_cost
|
||||
)
|
||||
|
||||
assert 'message' in result
|
||||
assert "No worktime entries found" in result['message']
|
||||
|
||||
def test_get_worktime_report(self):
|
||||
"""Test comprehensive worktime reporting."""
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
# Create test data across multiple days and issues
|
||||
self.tracker.log_worktime(122, 90, work_date=yesterday)
|
||||
self.tracker.log_worktime(123, 60, work_date=yesterday)
|
||||
self.tracker.log_worktime(122, 120, work_date=today)
|
||||
self.tracker.log_worktime(124, 45, work_date=today)
|
||||
|
||||
report = self.tracker.get_worktime_report(
|
||||
start_date=yesterday,
|
||||
end_date=today
|
||||
)
|
||||
|
||||
assert report['total_entries'] == 4
|
||||
assert report['total_time']['total_minutes'] == 315 # 90+60+120+45
|
||||
assert report['total_time']['hours'] == 5
|
||||
assert report['total_time']['minutes'] == 15
|
||||
assert report['unique_issues'] == 3 # Issues 122, 123, 124
|
||||
assert report['unique_dates'] == 2
|
||||
|
||||
# Check issue breakdown
|
||||
assert 122 in report['issue_breakdown']
|
||||
assert report['issue_breakdown'][122]['total_minutes'] == 210 # 90+120
|
||||
assert report['issue_breakdown'][122]['entry_count'] == 2
|
||||
assert report['issue_breakdown'][122]['unique_dates'] == 2
|
||||
|
||||
def test_delete_worktime_entry(self):
|
||||
"""Test deleting worktime entries."""
|
||||
entry_id = self.tracker.log_worktime(122, 90, description="Test entry")
|
||||
|
||||
# Verify entry exists
|
||||
entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(entries) == 1
|
||||
|
||||
# Delete entry
|
||||
success = self.tracker.delete_worktime_entry(entry_id)
|
||||
assert success is True
|
||||
|
||||
# Verify entry is gone
|
||||
entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(entries) == 0
|
||||
|
||||
# Try to delete non-existent entry
|
||||
success = self.tracker.delete_worktime_entry(99999)
|
||||
assert success is False
|
||||
|
||||
def test_update_worktime_entry(self):
|
||||
"""Test updating worktime entries."""
|
||||
entry_id = self.tracker.log_worktime(122, 90, description="Original description")
|
||||
|
||||
# Update duration and description
|
||||
success = self.tracker.update_worktime_entry(
|
||||
entry_id=entry_id,
|
||||
duration_minutes=120,
|
||||
description="Updated description"
|
||||
)
|
||||
assert success is True
|
||||
|
||||
# Verify updates
|
||||
entries = self.tracker.get_worktime_entries(issue_id=122)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].duration_minutes == 120
|
||||
assert entries[0].description == "Updated description"
|
||||
|
||||
# Try to update non-existent entry
|
||||
success = self.tracker.update_worktime_entry(
|
||||
entry_id=99999,
|
||||
duration_minutes=60
|
||||
)
|
||||
assert success is False
|
||||
|
||||
|
||||
class TestWorktimeCommands:
|
||||
"""Test suite for worktime CLI commands."""
|
||||
|
||||
def test_parse_duration_minutes(self):
|
||||
"""Test parsing duration strings - minutes format."""
|
||||
assert _parse_duration("90") == 90
|
||||
assert _parse_duration("120") == 120
|
||||
assert _parse_duration("45m") == 45
|
||||
|
||||
def test_parse_duration_hours(self):
|
||||
"""Test parsing duration strings - hours format."""
|
||||
assert _parse_duration("1h") == 60
|
||||
assert _parse_duration("2h") == 120
|
||||
assert _parse_duration("1.5h") == 90
|
||||
assert _parse_duration("2.25h") == 135
|
||||
|
||||
def test_parse_duration_hours_minutes(self):
|
||||
"""Test parsing duration strings - hours and minutes format."""
|
||||
assert _parse_duration("1h30m") == 90
|
||||
assert _parse_duration("2h15m") == 135
|
||||
assert _parse_duration("0h45m") == 45
|
||||
assert _parse_duration("3h0m") == 180
|
||||
|
||||
def test_parse_duration_invalid(self):
|
||||
"""Test parsing invalid duration strings."""
|
||||
with pytest.raises(ValueError):
|
||||
_parse_duration("invalid")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_parse_duration("1x30m")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_parse_duration("")
|
||||
|
||||
def test_format_duration_minutes_only(self):
|
||||
"""Test formatting duration - minutes only."""
|
||||
assert _format_duration(30) == "30m"
|
||||
assert _format_duration(45) == "45m"
|
||||
assert _format_duration(59) == "59m"
|
||||
|
||||
def test_format_duration_hours_only(self):
|
||||
"""Test formatting duration - hours only."""
|
||||
assert _format_duration(60) == "1h"
|
||||
assert _format_duration(120) == "2h"
|
||||
assert _format_duration(180) == "3h"
|
||||
|
||||
def test_format_duration_hours_and_minutes(self):
|
||||
"""Test formatting duration - hours and minutes."""
|
||||
assert _format_duration(90) == "1h30m"
|
||||
assert _format_duration(135) == "2h15m"
|
||||
assert _format_duration(195) == "3h15m"
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_log_command_basic(self, mock_tracker_class):
|
||||
"""Test the log command with basic parameters."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
mock_tracker.log_worktime.return_value = 1
|
||||
mock_tracker.get_daily_summary.return_value = DailySummary(
|
||||
work_date=date.today(),
|
||||
total_minutes=90,
|
||||
issue_count=1,
|
||||
entries=[]
|
||||
)
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(worktime, ['log', '122', '1h30m'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Logged 90min worktime for issue #122" in result.output
|
||||
mock_tracker.log_worktime.assert_called_once()
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_log_command_with_description(self, mock_tracker_class):
|
||||
"""Test the log command with description."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
mock_tracker.log_worktime.return_value = 1
|
||||
mock_tracker.get_daily_summary.return_value = None
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(worktime, ['log', '122', '90', '--description', 'Testing worktime'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Testing worktime" in result.output
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_list_command_table_format(self, mock_tracker_class):
|
||||
"""Test the list command with table output format."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
|
||||
mock_entries = [
|
||||
WorktimeEntry(
|
||||
id=1,
|
||||
issue_id=122,
|
||||
work_date=date.today(),
|
||||
duration_minutes=90,
|
||||
description="Test worktime",
|
||||
entry_type="manual"
|
||||
)
|
||||
]
|
||||
mock_tracker.get_worktime_entries.return_value = mock_entries
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(worktime, ['list'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "⏰ Worktime Entries" in result.output
|
||||
assert "#122" in result.output
|
||||
assert "1h30m" in result.output
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_list_command_json_format(self, mock_tracker_class):
|
||||
"""Test the list command with JSON output format."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
|
||||
mock_entries = [
|
||||
WorktimeEntry(
|
||||
id=1,
|
||||
issue_id=122,
|
||||
work_date=date.today(),
|
||||
duration_minutes=90,
|
||||
description="Test worktime",
|
||||
entry_type="manual"
|
||||
)
|
||||
]
|
||||
mock_tracker.get_worktime_entries.return_value = mock_entries
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(worktime, ['list', '--format', 'json'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Should be valid JSON
|
||||
output_data = json.loads(result.output.strip())
|
||||
assert len(output_data) == 1
|
||||
assert output_data[0]['issue_id'] == 122
|
||||
assert output_data[0]['duration_minutes'] == 90
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_daily_command(self, mock_tracker_class):
|
||||
"""Test the daily summary command."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
|
||||
mock_entries = [
|
||||
WorktimeEntry(id=1, issue_id=122, duration_minutes=90, entry_type="manual"),
|
||||
WorktimeEntry(id=2, issue_id=123, duration_minutes=60, entry_type="manual")
|
||||
]
|
||||
|
||||
mock_summary = DailySummary(
|
||||
work_date=date.today(),
|
||||
total_minutes=150,
|
||||
issue_count=2,
|
||||
entries=mock_entries,
|
||||
cost_per_minute=Decimal('0.5'),
|
||||
total_cost_allocated=Decimal('75.0')
|
||||
)
|
||||
mock_tracker.get_daily_summary.return_value = mock_summary
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
result = runner.invoke(worktime, ['daily', today])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"📅 Daily Summary for {date.today()}" in result.output
|
||||
assert "Total Time: 2h30m" in result.output
|
||||
assert "Issues Worked: 2" in result.output
|
||||
assert "Cost per Minute: €0.5000" in result.output
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_estimate_command(self, mock_tracker_class):
|
||||
"""Test the estimate worktime command."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
|
||||
mock_result = {
|
||||
'work_date': date.today(),
|
||||
'total_minutes': 480, # 8 hours
|
||||
'distribution_method': 'equal',
|
||||
'issue_estimates': {122: 240, 123: 240},
|
||||
'issues_count': 2
|
||||
}
|
||||
mock_tracker.estimate_daily_worktime.return_value = mock_result
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
result = runner.invoke(worktime, ['estimate', today, '8', '-i', '122', '-i', '123'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "📊 Worktime Estimation" in result.output
|
||||
assert "Total Hours: 8.0h" in result.output
|
||||
assert "✅ Created 2 estimated worktime entries" in result.output
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_distribute_command(self, mock_tracker_class):
|
||||
"""Test the cost distribution command."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
|
||||
mock_result = {
|
||||
'work_date': date.today(),
|
||||
'total_cost': 100.0,
|
||||
'total_minutes': 200,
|
||||
'cost_per_minute': 0.5,
|
||||
'distributions': {
|
||||
122: {'minutes': 120, 'percentage': 60.0, 'cost_allocated': 60.0},
|
||||
123: {'minutes': 80, 'percentage': 40.0, 'cost_allocated': 40.0}
|
||||
},
|
||||
'issues_count': 2
|
||||
}
|
||||
mock_tracker.distribute_daily_costs.return_value = mock_result
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
result = runner.invoke(worktime, ['distribute', today, '100'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "💰 Cost Distribution" in result.output
|
||||
assert "Total Cost: €100.00" in result.output
|
||||
assert "Cost per Minute: €0.5000" in result.output
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_delete_command(self, mock_tracker_class):
|
||||
"""Test the delete command."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
mock_tracker.delete_worktime_entry.return_value = True
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
# Auto-confirm the deletion
|
||||
result = runner.invoke(worktime, ['delete', '1'], input='y\n')
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Deleted worktime entry #1" in result.output
|
||||
mock_tracker.delete_worktime_entry.assert_called_once_with(1)
|
||||
|
||||
@patch('markitect.finance.worktime_commands.WorktimeTracker')
|
||||
def test_update_command(self, mock_tracker_class):
|
||||
"""Test the update command."""
|
||||
mock_tracker = Mock()
|
||||
mock_tracker_class.return_value = mock_tracker
|
||||
mock_tracker.update_worktime_entry.return_value = True
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(worktime, ['update', '1', '--duration', '2h', '--description', 'Updated'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Updated worktime entry #1" in result.output
|
||||
|
||||
|
||||
class TestWorktimeIntegration:
|
||||
"""Integration tests for the complete worktime tracking system."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up integration test fixtures."""
|
||||
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
||||
self.temp_db.close()
|
||||
self.db_path = self.temp_db.name
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up integration test fixtures."""
|
||||
Path(self.db_path).unlink(missing_ok=True)
|
||||
|
||||
def test_full_worktime_lifecycle(self):
|
||||
"""Test the complete lifecycle of worktime tracking."""
|
||||
tracker = WorktimeTracker(self.db_path)
|
||||
|
||||
# 1. Log worktime for multiple issues across multiple days
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
tracker.log_worktime(122, 120, work_date=yesterday, description="Initial development")
|
||||
tracker.log_worktime(123, 90, work_date=yesterday, description="Code review")
|
||||
|
||||
tracker.log_worktime(122, 90, work_date=today, description="Bug fixes")
|
||||
tracker.log_worktime(124, 60, work_date=today, description="Documentation")
|
||||
|
||||
# 2. Verify daily summaries
|
||||
yesterday_summary = tracker.get_daily_summary(yesterday)
|
||||
assert yesterday_summary.total_minutes == 210 # 120 + 90
|
||||
assert yesterday_summary.issue_count == 2
|
||||
|
||||
today_summary = tracker.get_daily_summary(today)
|
||||
assert today_summary.total_minutes == 150 # 90 + 60
|
||||
assert today_summary.issue_count == 2
|
||||
|
||||
# 3. Distribute costs for a day
|
||||
distribution = tracker.distribute_daily_costs(
|
||||
work_date=today,
|
||||
total_daily_cost=Decimal('75.00') # €75 for today's work
|
||||
)
|
||||
|
||||
assert distribution['total_cost'] == 75.0
|
||||
assert distribution['total_minutes'] == 150
|
||||
assert distribution['cost_per_minute'] == 0.5
|
||||
|
||||
# Issue 122: 90 minutes = €45
|
||||
# Issue 124: 60 minutes = €30
|
||||
assert distribution['distributions'][122]['cost_allocated'] == 45.0
|
||||
assert distribution['distributions'][124]['cost_allocated'] == 30.0
|
||||
|
||||
# 4. Generate comprehensive report
|
||||
report = tracker.get_worktime_report(
|
||||
start_date=yesterday,
|
||||
end_date=today
|
||||
)
|
||||
|
||||
assert report['total_entries'] == 4
|
||||
assert report['total_time']['total_minutes'] == 360 # 210 + 150
|
||||
assert report['unique_issues'] == 3 # Issues 122, 123, 124
|
||||
assert report['unique_dates'] == 2
|
||||
|
||||
# 5. Test estimation functionality
|
||||
tomorrow = today + timedelta(days=1)
|
||||
estimation = tracker.estimate_daily_worktime(
|
||||
work_date=tomorrow,
|
||||
total_hours=6.0,
|
||||
issues=[122, 125, 126],
|
||||
distribution_method="equal"
|
||||
)
|
||||
|
||||
assert estimation['total_minutes'] == 360
|
||||
assert len(estimation['issue_estimates']) == 3
|
||||
# Each issue should get 120 minutes (equal distribution)
|
||||
for minutes in estimation['issue_estimates'].values():
|
||||
assert minutes == 120
|
||||
|
||||
# 6. Verify estimated entries were created
|
||||
tomorrow_entries = tracker.get_worktime_entries(work_date=tomorrow)
|
||||
assert len(tomorrow_entries) == 3
|
||||
assert all(e.entry_type == "estimated" for e in tomorrow_entries)
|
||||
|
||||
def test_cost_distribution_accuracy(self):
|
||||
"""Test accurate cost distribution calculations."""
|
||||
tracker = WorktimeTracker(self.db_path)
|
||||
work_date = date.today()
|
||||
|
||||
# Log precise worktime amounts
|
||||
tracker.log_worktime(122, 100, work_date=work_date) # 100 minutes
|
||||
tracker.log_worktime(123, 50, work_date=work_date) # 50 minutes
|
||||
tracker.log_worktime(124, 150, work_date=work_date) # 150 minutes
|
||||
# Total: 300 minutes
|
||||
|
||||
# Distribute exactly €300
|
||||
distribution = tracker.distribute_daily_costs(
|
||||
work_date=work_date,
|
||||
total_daily_cost=Decimal('300.00')
|
||||
)
|
||||
|
||||
# Should be exactly €1 per minute
|
||||
assert distribution['cost_per_minute'] == 1.0
|
||||
|
||||
# Verify exact cost allocation
|
||||
assert distribution['distributions'][122]['cost_allocated'] == 100.0
|
||||
assert distribution['distributions'][123]['cost_allocated'] == 50.0
|
||||
assert distribution['distributions'][124]['cost_allocated'] == 150.0
|
||||
|
||||
# Verify percentages sum to 100%
|
||||
total_percentage = sum(
|
||||
dist['percentage'] for dist in distribution['distributions'].values()
|
||||
)
|
||||
assert abs(total_percentage - 100.0) < 0.01 # Allow for rounding
|
||||
|
||||
# Verify cost allocation was logged to database
|
||||
with tracker.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT issue_id, cost_allocated
|
||||
FROM worktime_cost_distributions
|
||||
WHERE work_date = ?
|
||||
ORDER BY issue_id
|
||||
''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date,))
|
||||
results = cursor.fetchall()
|
||||
|
||||
assert len(results) == 3
|
||||
assert results[0] == (122, 100.0)
|
||||
assert results[1] == (123, 50.0)
|
||||
assert results[2] == (124, 150.0)
|
||||
|
||||
def test_worktime_modification_and_summary_updates(self):
|
||||
"""Test that modifying worktime entries correctly updates summaries."""
|
||||
tracker = WorktimeTracker(self.db_path)
|
||||
work_date = date.today()
|
||||
|
||||
# Log initial worktime
|
||||
entry_id = tracker.log_worktime(122, 60, work_date=work_date)
|
||||
|
||||
# Check initial summary
|
||||
summary = tracker.get_daily_summary(work_date)
|
||||
assert summary.total_minutes == 60
|
||||
|
||||
# Update the entry
|
||||
tracker.update_worktime_entry(entry_id, duration_minutes=120)
|
||||
|
||||
# Check updated summary
|
||||
summary = tracker.get_daily_summary(work_date)
|
||||
assert summary.total_minutes == 120
|
||||
|
||||
# Delete the entry
|
||||
tracker.delete_worktime_entry(entry_id)
|
||||
|
||||
# Check final summary
|
||||
summary = tracker.get_daily_summary(work_date)
|
||||
assert summary is None or summary.total_minutes == 0
|
||||
@@ -1,855 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #19: Plugin Architecture and Extensions System
|
||||
|
||||
This module provides comprehensive tests for the MarkiTect plugin system
|
||||
including plugin discovery, loading, management, and CLI integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from markitect.plugins import (
|
||||
PluginManager,
|
||||
BasePlugin,
|
||||
ProcessorPlugin,
|
||||
FormatterPlugin,
|
||||
PluginType,
|
||||
PluginMetadata,
|
||||
plugin_registry,
|
||||
register_plugin
|
||||
)
|
||||
from markitect.plugins.manager import PluginManager
|
||||
from markitect.plugins.registry import PluginRegistry
|
||||
|
||||
|
||||
class TestPluginArchitecture:
|
||||
"""Test suite for plugin architecture components."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
# Clear plugin registry for clean tests
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after tests."""
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
|
||||
class TestPluginBase:
|
||||
"""Test base plugin functionality."""
|
||||
|
||||
def test_plugin_metadata_creation(self):
|
||||
"""Test PluginMetadata creation and properties."""
|
||||
metadata = PluginMetadata(
|
||||
name="test_plugin",
|
||||
version="1.0.0",
|
||||
description="Test plugin",
|
||||
author="Test Author",
|
||||
plugin_type=PluginType.PROCESSOR,
|
||||
dependencies=["dep1", "dep2"],
|
||||
markitect_version=">=0.1.0"
|
||||
)
|
||||
|
||||
assert metadata.name == "test_plugin"
|
||||
assert metadata.version == "1.0.0"
|
||||
assert metadata.description == "Test plugin"
|
||||
assert metadata.author == "Test Author"
|
||||
assert metadata.plugin_type == PluginType.PROCESSOR
|
||||
assert metadata.dependencies == ["dep1", "dep2"]
|
||||
assert metadata.markitect_version == ">=0.1.0"
|
||||
|
||||
def test_base_plugin_initialization(self):
|
||||
"""Test BasePlugin initialization."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
config = {"option1": "value1", "option2": "value2"}
|
||||
plugin = TestPlugin(config)
|
||||
|
||||
assert plugin.config == config
|
||||
assert not plugin.is_initialized
|
||||
|
||||
def test_plugin_initialization_lifecycle(self):
|
||||
"""Test plugin initialization and cleanup lifecycle."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
def __init__(self, config=None):
|
||||
super().__init__(config)
|
||||
self.initialized = False
|
||||
self.cleaned_up = False
|
||||
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
def _initialize(self):
|
||||
self.initialized = True
|
||||
|
||||
def cleanup(self):
|
||||
self.cleaned_up = True
|
||||
|
||||
plugin = TestPlugin()
|
||||
assert not plugin.initialized
|
||||
assert not plugin.is_initialized
|
||||
|
||||
# Test initialization
|
||||
result = plugin.initialize()
|
||||
assert result is True
|
||||
assert plugin.initialized
|
||||
assert plugin.is_initialized
|
||||
|
||||
# Test cleanup
|
||||
plugin.cleanup()
|
||||
assert plugin.cleaned_up
|
||||
|
||||
def test_plugin_initialization_failure(self):
|
||||
"""Test plugin initialization failure handling."""
|
||||
|
||||
class FailingPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="failing",
|
||||
version="1.0.0",
|
||||
description="Failing plugin",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
def _initialize(self):
|
||||
raise Exception("Initialization failed")
|
||||
|
||||
plugin = FailingPlugin()
|
||||
result = plugin.initialize()
|
||||
assert result is False
|
||||
assert not plugin.is_initialized
|
||||
|
||||
|
||||
class TestProcessorPlugin:
|
||||
"""Test processor plugin functionality."""
|
||||
|
||||
def test_processor_plugin_interface(self):
|
||||
"""Test processor plugin interface implementation."""
|
||||
|
||||
class TestProcessor(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test_processor",
|
||||
version="1.0.0",
|
||||
description="Test processor",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content: str, **kwargs) -> str:
|
||||
return content.upper()
|
||||
|
||||
processor = TestProcessor()
|
||||
result = processor.process("hello world")
|
||||
assert result == "HELLO WORLD"
|
||||
|
||||
# Test default can_process implementation
|
||||
assert processor.can_process("any content")
|
||||
|
||||
def test_processor_plugin_with_options(self):
|
||||
"""Test processor plugin with processing options."""
|
||||
|
||||
class ConfigurableProcessor(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="configurable_processor",
|
||||
version="1.0.0",
|
||||
description="Configurable processor",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content: str, **kwargs) -> str:
|
||||
if kwargs.get('uppercase', False):
|
||||
content = content.upper()
|
||||
if kwargs.get('reverse', False):
|
||||
content = content[::-1]
|
||||
return content
|
||||
|
||||
processor = ConfigurableProcessor()
|
||||
|
||||
# Test with no options
|
||||
result = processor.process("hello")
|
||||
assert result == "hello"
|
||||
|
||||
# Test with uppercase option
|
||||
result = processor.process("hello", uppercase=True)
|
||||
assert result == "HELLO"
|
||||
|
||||
# Test with both options
|
||||
result = processor.process("hello", uppercase=True, reverse=True)
|
||||
assert result == "OLLEH"
|
||||
|
||||
|
||||
class TestFormatterPlugin:
|
||||
"""Test formatter plugin functionality."""
|
||||
|
||||
def test_formatter_plugin_interface(self):
|
||||
"""Test formatter plugin interface implementation."""
|
||||
|
||||
class TestFormatter(FormatterPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test_formatter",
|
||||
version="1.0.0",
|
||||
description="Test formatter",
|
||||
plugin_type=PluginType.FORMATTER
|
||||
)
|
||||
|
||||
def format(self, data, **kwargs) -> str:
|
||||
return json.dumps(data, indent=kwargs.get('indent', 2))
|
||||
|
||||
def get_file_extension(self) -> str:
|
||||
return '.json'
|
||||
|
||||
formatter = TestFormatter()
|
||||
data = {"key": "value", "number": 42}
|
||||
|
||||
result = formatter.format(data)
|
||||
parsed = json.loads(result)
|
||||
assert parsed == data
|
||||
|
||||
extension = formatter.get_file_extension()
|
||||
assert extension == '.json'
|
||||
|
||||
|
||||
class TestPluginRegistry:
|
||||
"""Test plugin registry functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.registry = PluginRegistry()
|
||||
|
||||
def test_plugin_registration(self):
|
||||
"""Test plugin registration."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
# Test registration
|
||||
name = self.registry.register(TestPlugin)
|
||||
assert name == "TestPlugin"
|
||||
assert "TestPlugin" in self.registry._plugins
|
||||
|
||||
# Test registration with custom name
|
||||
custom_name = self.registry.register(TestPlugin, "custom_name")
|
||||
assert custom_name == "custom_name"
|
||||
assert "custom_name" in self.registry._plugins
|
||||
|
||||
def test_plugin_registration_duplicate_name(self):
|
||||
"""Test plugin registration with duplicate name."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
self.registry.register(TestPlugin, "test_name")
|
||||
|
||||
# Should raise error for duplicate name
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
self.registry.register(TestPlugin, "test_name")
|
||||
|
||||
def test_plugin_retrieval(self):
|
||||
"""Test plugin retrieval from registry."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
self.registry.register(TestPlugin, "test_plugin")
|
||||
|
||||
# Test successful retrieval
|
||||
plugin = self.registry.get_plugin("test_plugin")
|
||||
assert plugin is not None
|
||||
assert isinstance(plugin, TestPlugin)
|
||||
|
||||
# Test non-existent plugin
|
||||
plugin = self.registry.get_plugin("non_existent")
|
||||
assert plugin is None
|
||||
|
||||
def test_plugin_unregistration(self):
|
||||
"""Test plugin unregistration."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
self.registry.register(TestPlugin, "test_plugin")
|
||||
plugin = self.registry.get_plugin("test_plugin")
|
||||
assert plugin is not None
|
||||
|
||||
# Test unregistration
|
||||
result = self.registry.unregister("test_plugin")
|
||||
assert result is True
|
||||
|
||||
# Plugin should no longer be available
|
||||
plugin = self.registry.get_plugin("test_plugin")
|
||||
assert plugin is None
|
||||
|
||||
# Test unregistering non-existent plugin
|
||||
result = self.registry.unregister("non_existent")
|
||||
assert result is False
|
||||
|
||||
def test_plugins_by_type(self):
|
||||
"""Test retrieving plugins by type."""
|
||||
|
||||
class ProcessorPlugin1(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="processor1",
|
||||
version="1.0.0",
|
||||
description="Processor 1",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content, **kwargs):
|
||||
return content
|
||||
|
||||
class FormatterPlugin1(FormatterPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="formatter1",
|
||||
version="1.0.0",
|
||||
description="Formatter 1",
|
||||
plugin_type=PluginType.FORMATTER
|
||||
)
|
||||
|
||||
def format(self, data, **kwargs):
|
||||
return str(data)
|
||||
|
||||
def get_file_extension(self):
|
||||
return '.txt'
|
||||
|
||||
self.registry.register(ProcessorPlugin1, "processor1")
|
||||
self.registry.register(FormatterPlugin1, "formatter1")
|
||||
|
||||
# Test getting processors
|
||||
processors = self.registry.get_plugins_by_type(PluginType.PROCESSOR)
|
||||
assert "processor1" in processors
|
||||
assert "formatter1" not in processors
|
||||
|
||||
# Test getting formatters
|
||||
formatters = self.registry.get_plugins_by_type(PluginType.FORMATTER)
|
||||
assert "formatter1" in formatters
|
||||
assert "processor1" not in formatters
|
||||
|
||||
def test_list_plugins(self):
|
||||
"""Test listing all plugins with metadata."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test plugin",
|
||||
author="Test Author",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
self.registry.register(TestPlugin, "test_plugin")
|
||||
|
||||
plugins = self.registry.list_plugins()
|
||||
assert "test_plugin" in plugins
|
||||
|
||||
plugin_info = plugins["test_plugin"]
|
||||
assert plugin_info["name"] == "test"
|
||||
assert plugin_info["version"] == "1.0.0"
|
||||
assert plugin_info["description"] == "Test plugin"
|
||||
assert plugin_info["author"] == "Test Author"
|
||||
assert plugin_info["type"] == "extension"
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test plugin manager functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
# Clear plugin registry
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
def test_plugin_manager_initialization(self):
|
||||
"""Test plugin manager initialization."""
|
||||
manager = PluginManager()
|
||||
assert manager.config is not None
|
||||
assert isinstance(manager.plugin_directories, list)
|
||||
|
||||
def test_plugin_manager_with_config(self):
|
||||
"""Test plugin manager with custom configuration."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
|
||||
f.write("""
|
||||
plugin_directories:
|
||||
- "custom_plugins"
|
||||
auto_discover: false
|
||||
plugins:
|
||||
test_plugin:
|
||||
enabled: true
|
||||
""")
|
||||
config_path = f.name
|
||||
|
||||
try:
|
||||
manager = PluginManager(config_path)
|
||||
assert "custom_plugins" in manager.config.get('plugin_directories', [])
|
||||
assert manager.config.get('auto_discover') is False
|
||||
assert 'test_plugin' in manager.config.get('plugins', {})
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def test_plugin_discovery_empty(self):
|
||||
"""Test plugin discovery with no plugins."""
|
||||
manager = PluginManager()
|
||||
discovered = manager.discover_plugins()
|
||||
# Should be a dictionary (empty or with built-ins)
|
||||
assert isinstance(discovered, dict)
|
||||
|
||||
@patch('importlib.import_module')
|
||||
def test_load_plugin_success(self, mock_import):
|
||||
"""Test successful plugin loading."""
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
description="Test",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
# Mock module with plugin
|
||||
mock_module = Mock()
|
||||
mock_module.TestPlugin = TestPlugin
|
||||
mock_import.return_value = mock_module
|
||||
|
||||
manager = PluginManager()
|
||||
|
||||
# Manually add to discovered plugins
|
||||
manager._discovered_plugins = {
|
||||
"test_plugin": {
|
||||
"module_name": "test_module",
|
||||
"class_name": "TestPlugin"
|
||||
}
|
||||
}
|
||||
|
||||
plugin = manager.load_plugin("test_plugin")
|
||||
assert plugin is not None
|
||||
assert isinstance(plugin, TestPlugin)
|
||||
|
||||
def test_load_plugin_not_found(self):
|
||||
"""Test loading non-existent plugin."""
|
||||
manager = PluginManager()
|
||||
plugin = manager.load_plugin("non_existent_plugin")
|
||||
assert plugin is None
|
||||
|
||||
def test_get_plugins_by_type(self):
|
||||
"""Test getting plugins by type."""
|
||||
|
||||
class TestProcessor(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="test_processor",
|
||||
version="1.0.0",
|
||||
description="Test processor",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content, **kwargs):
|
||||
return content
|
||||
|
||||
# Register plugin directly
|
||||
plugin_registry.register(TestProcessor, "test_processor")
|
||||
|
||||
manager = PluginManager()
|
||||
processors = manager.get_plugins_by_type(PluginType.PROCESSOR)
|
||||
|
||||
# Should have at least our test processor
|
||||
assert len(processors) >= 1
|
||||
assert any(isinstance(p, TestProcessor) for p in processors)
|
||||
|
||||
|
||||
class TestPluginDecorator:
|
||||
"""Test plugin registration decorator."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
# Clear plugin registry
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
def test_register_plugin_decorator(self):
|
||||
"""Test @register_plugin decorator."""
|
||||
|
||||
@register_plugin("decorated_plugin")
|
||||
class DecoratedPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="decorated",
|
||||
version="1.0.0",
|
||||
description="Decorated plugin",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
# Plugin should be automatically registered
|
||||
assert "decorated_plugin" in plugin_registry._plugins
|
||||
|
||||
# Should be able to retrieve it
|
||||
plugin = plugin_registry.get_plugin("decorated_plugin")
|
||||
assert plugin is not None
|
||||
assert isinstance(plugin, DecoratedPlugin)
|
||||
|
||||
def test_register_plugin_decorator_no_name(self):
|
||||
"""Test @register_plugin decorator without name."""
|
||||
|
||||
@register_plugin()
|
||||
class AutoNamedPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="auto_named",
|
||||
version="1.0.0",
|
||||
description="Auto named plugin",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
# Should use class name
|
||||
assert "AutoNamedPlugin" in plugin_registry._plugins
|
||||
|
||||
|
||||
class TestBuiltinPlugins:
|
||||
"""Test built-in plugins."""
|
||||
|
||||
def test_json_formatter_plugin(self):
|
||||
"""Test built-in JSON formatter plugin."""
|
||||
from markitect.plugins.builtin.formatters import JsonFormatter
|
||||
|
||||
formatter = JsonFormatter()
|
||||
assert formatter.metadata.plugin_type == PluginType.FORMATTER
|
||||
|
||||
data = {"key": "value", "number": 42}
|
||||
result = formatter.format(data)
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed == data
|
||||
|
||||
assert formatter.get_file_extension() == '.json'
|
||||
|
||||
def test_table_formatter_plugin(self):
|
||||
"""Test built-in table formatter plugin."""
|
||||
from markitect.plugins.builtin.formatters import TableFormatter
|
||||
|
||||
formatter = TableFormatter()
|
||||
assert formatter.metadata.plugin_type == PluginType.FORMATTER
|
||||
|
||||
# Test with list of dictionaries
|
||||
data = [
|
||||
{"name": "John", "age": 30},
|
||||
{"name": "Jane", "age": 25}
|
||||
]
|
||||
|
||||
result = formatter.format(data)
|
||||
assert "John" in result
|
||||
assert "Jane" in result
|
||||
assert "name" in result
|
||||
assert "age" in result
|
||||
|
||||
assert formatter.get_file_extension() == '.txt'
|
||||
|
||||
def test_markdown_processor_plugin(self):
|
||||
"""Test built-in markdown processor plugin."""
|
||||
from markitect.plugins.builtin.processors import MarkdownProcessor
|
||||
|
||||
processor = MarkdownProcessor()
|
||||
assert processor.metadata.plugin_type == PluginType.PROCESSOR
|
||||
|
||||
# Test basic processing
|
||||
content = "# Header\n\nSome content\n"
|
||||
result = processor.process(content)
|
||||
assert isinstance(result, str)
|
||||
|
||||
# Test can_process
|
||||
assert processor.can_process("# Markdown header")
|
||||
assert processor.can_process("Some **bold** text")
|
||||
|
||||
|
||||
class TestPluginCLIIntegration:
|
||||
"""Test plugin CLI command integration."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
# Clear plugin registry
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
def test_plugin_list_command_import(self):
|
||||
"""Test that plugin CLI commands can be imported."""
|
||||
# This tests that the CLI commands are properly integrated
|
||||
from markitect.cli import plugin_list, plugin_load, plugin_info
|
||||
|
||||
assert callable(plugin_list)
|
||||
assert callable(plugin_load)
|
||||
assert callable(plugin_info)
|
||||
|
||||
def test_plugin_type_enum_import(self):
|
||||
"""Test that PluginType enum is accessible for CLI."""
|
||||
from markitect.plugins.base import PluginType
|
||||
|
||||
# Test all plugin types are available
|
||||
assert PluginType.PROCESSOR
|
||||
assert PluginType.FORMATTER
|
||||
assert PluginType.VALIDATOR
|
||||
assert PluginType.EXPORTER
|
||||
assert PluginType.GENERATOR
|
||||
assert PluginType.IMPORTER
|
||||
assert PluginType.TRANSFORMER
|
||||
assert PluginType.EXTENSION
|
||||
assert PluginType.BACKEND
|
||||
assert PluginType.COMMAND
|
||||
|
||||
# Test values are strings
|
||||
assert isinstance(PluginType.PROCESSOR.value, str)
|
||||
|
||||
|
||||
class TestPluginErrorHandling:
|
||||
"""Test plugin error handling and edge cases."""
|
||||
|
||||
def test_plugin_with_invalid_metadata(self):
|
||||
"""Test plugin with invalid metadata."""
|
||||
|
||||
class BadMetadataPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
# Missing required fields
|
||||
return None
|
||||
|
||||
plugin = BadMetadataPlugin()
|
||||
|
||||
# Should handle gracefully
|
||||
try:
|
||||
plugin_registry.register(BadMetadataPlugin, "bad_plugin")
|
||||
# Should not crash, might register as extension type
|
||||
except Exception:
|
||||
# Exception is acceptable for invalid metadata
|
||||
pass
|
||||
|
||||
def test_plugin_initialization_with_bad_config(self):
|
||||
"""Test plugin initialization with invalid configuration."""
|
||||
|
||||
class ConfigValidatingPlugin(BasePlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="config_validator",
|
||||
version="1.0.0",
|
||||
description="Config validating plugin",
|
||||
plugin_type=PluginType.EXTENSION
|
||||
)
|
||||
|
||||
def validate_config(self):
|
||||
errors = []
|
||||
if 'required_field' not in self.config:
|
||||
errors.append("Missing required_field")
|
||||
return errors
|
||||
|
||||
# Test with invalid config
|
||||
plugin = ConfigValidatingPlugin({"wrong_field": "value"})
|
||||
errors = plugin.validate_config()
|
||||
assert len(errors) > 0
|
||||
assert "required_field" in errors[0]
|
||||
|
||||
# Test with valid config
|
||||
plugin = ConfigValidatingPlugin({"required_field": "value"})
|
||||
errors = plugin.validate_config()
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_plugin_manager_with_invalid_config_file(self):
|
||||
"""Test plugin manager with invalid configuration file."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
|
||||
f.write("invalid: yaml: content: [") # Invalid YAML
|
||||
config_path = f.name
|
||||
|
||||
try:
|
||||
# Should not crash, should use defaults
|
||||
manager = PluginManager(config_path)
|
||||
assert manager.config is not None
|
||||
# Should fall back to defaults
|
||||
assert 'plugin_directories' in manager.config
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
class TestPluginIntegration:
|
||||
"""Integration tests for the plugin system."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
# Clear plugin registry
|
||||
plugin_registry.cleanup_all()
|
||||
plugin_registry._plugins.clear()
|
||||
plugin_registry._instances.clear()
|
||||
plugin_registry._plugins_by_type.clear()
|
||||
|
||||
def test_end_to_end_plugin_workflow(self):
|
||||
"""Test complete plugin workflow from registration to usage."""
|
||||
|
||||
# 1. Create a plugin
|
||||
@register_plugin("workflow_processor")
|
||||
class WorkflowProcessor(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="workflow_processor",
|
||||
version="1.0.0",
|
||||
description="End-to-end workflow processor",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content, **kwargs):
|
||||
prefix = kwargs.get('prefix', self.config.get('prefix', ''))
|
||||
return f"{prefix}{content}"
|
||||
|
||||
# 2. Verify registration
|
||||
assert "workflow_processor" in plugin_registry._plugins
|
||||
|
||||
# 3. Create manager and load plugin
|
||||
manager = PluginManager()
|
||||
plugin = manager.load_plugin("workflow_processor", {"prefix": ">> "})
|
||||
|
||||
# 4. Use plugin
|
||||
assert plugin is not None
|
||||
result = plugin.process("Hello World")
|
||||
assert result == ">> Hello World"
|
||||
|
||||
# 5. Verify plugin is in registry
|
||||
assert plugin_registry.is_loaded("workflow_processor")
|
||||
|
||||
# 6. Get plugin by type
|
||||
processors = manager.get_plugins_by_type(PluginType.PROCESSOR)
|
||||
assert any(isinstance(p, WorkflowProcessor) for p in processors)
|
||||
|
||||
# 7. Unload plugin
|
||||
success = manager.unload_plugin("workflow_processor")
|
||||
assert success is True
|
||||
assert not plugin_registry.is_loaded("workflow_processor")
|
||||
|
||||
def test_multiple_plugins_interaction(self):
|
||||
"""Test interaction between multiple plugins."""
|
||||
|
||||
# Register multiple plugins
|
||||
@register_plugin("upper_processor")
|
||||
class UpperProcessor(ProcessorPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="upper_processor",
|
||||
version="1.0.0",
|
||||
description="Uppercase processor",
|
||||
plugin_type=PluginType.PROCESSOR
|
||||
)
|
||||
|
||||
def process(self, content, **kwargs):
|
||||
return content.upper()
|
||||
|
||||
@register_plugin("json_test_formatter")
|
||||
class JsonTestFormatter(FormatterPlugin):
|
||||
@property
|
||||
def metadata(self):
|
||||
return PluginMetadata(
|
||||
name="json_test_formatter",
|
||||
version="1.0.0",
|
||||
description="JSON test formatter",
|
||||
plugin_type=PluginType.FORMATTER
|
||||
)
|
||||
|
||||
def format(self, data, **kwargs):
|
||||
return json.dumps(data)
|
||||
|
||||
def get_file_extension(self):
|
||||
return '.json'
|
||||
|
||||
manager = PluginManager()
|
||||
|
||||
# Load both plugins
|
||||
processor = manager.load_plugin("upper_processor")
|
||||
formatter = manager.load_plugin("json_test_formatter")
|
||||
|
||||
assert processor is not None
|
||||
assert formatter is not None
|
||||
|
||||
# Use them together
|
||||
processed = processor.process("hello world")
|
||||
formatted = formatter.format({"result": processed})
|
||||
|
||||
data = json.loads(formatted)
|
||||
assert data["result"] == "HELLO WORLD"
|
||||
|
||||
# Verify both are loaded
|
||||
assert plugin_registry.is_loaded("upper_processor")
|
||||
assert plugin_registry.is_loaded("json_test_formatter")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
@@ -1,397 +0,0 @@
|
||||
"""
|
||||
Test suite for Issue #46: Schema generation capability outline
|
||||
|
||||
This test module validates outline mode schema generation improvements including:
|
||||
- Heading text capture in outline mode schemas
|
||||
- Integration with draft generation using captured heading text
|
||||
- Proper title formatting and depth limiting
|
||||
- Content instruction integration
|
||||
- End-to-end workflow from example document to generated drafts
|
||||
|
||||
Created for Issue #46: https://gitea.coulomb.social/coulomb/markitect_project/issues/46
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
from markitect.cli import cli
|
||||
|
||||
|
||||
class TestIssue46SchemaGenerationOutline:
|
||||
"""Test suite for schema generation outline mode improvements."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.runner = CliRunner()
|
||||
|
||||
# Create a test markdown file with specific headings
|
||||
self.test_md_content = """# Project Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
This is the project overview section.
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
### Database Requirements
|
||||
|
||||
The database should support:
|
||||
- User management
|
||||
- Data persistence
|
||||
- Backup functionality
|
||||
|
||||
### API Requirements
|
||||
|
||||
The API should provide:
|
||||
- RESTful endpoints
|
||||
- Authentication
|
||||
- Rate limiting
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
This section covers the implementation approach.
|
||||
"""
|
||||
|
||||
def test_outline_mode_captures_actual_heading_text(self):
|
||||
"""Test that outline mode captures actual heading text in enum constraints."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate schema in outline mode with heading text capture
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--depth', '3',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
# Assert - Command should succeed
|
||||
assert result.exit_code == 0, f"Command failed: {result.output}"
|
||||
|
||||
# Parse the generated schema
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Should have correct title format
|
||||
assert schema['title'] == f"Schema from {md_file.name}"
|
||||
|
||||
# Should capture actual heading text in enum constraints
|
||||
level_1_content = schema['properties']['headings']['properties']['level_1']['items']['properties']['content']
|
||||
assert 'enum' in level_1_content
|
||||
assert "Project Requirements" in level_1_content['enum']
|
||||
|
||||
level_2_content = schema['properties']['headings']['properties']['level_2']['items']['properties']['content']
|
||||
assert 'enum' in level_2_content
|
||||
assert "Overview" in level_2_content['enum']
|
||||
assert "Technical Specifications" in level_2_content['enum']
|
||||
assert "Implementation Plan" in level_2_content['enum']
|
||||
|
||||
level_3_content = schema['properties']['headings']['properties']['level_3']['items']['properties']['content']
|
||||
assert 'enum' in level_3_content
|
||||
assert "Database Requirements" in level_3_content['enum']
|
||||
assert "API Requirements" in level_3_content['enum']
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
|
||||
def test_draft_generation_uses_captured_heading_text(self):
|
||||
"""Test that draft generation uses actual heading text from outline schema."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as draft_f:
|
||||
draft_file = Path(draft_f.name)
|
||||
|
||||
try:
|
||||
# Arrange - Generate outline schema with heading text capture
|
||||
schema_result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--depth', '3',
|
||||
'--outfile', str(schema_file),
|
||||
str(md_file)
|
||||
])
|
||||
assert schema_result.exit_code == 0
|
||||
|
||||
# Act - Generate draft from the outline schema
|
||||
draft_result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file),
|
||||
'--output', str(draft_file)
|
||||
])
|
||||
|
||||
# Assert - Draft generation should succeed
|
||||
assert draft_result.exit_code == 0, f"Draft generation failed: {draft_result.output}"
|
||||
|
||||
# Read the generated draft
|
||||
draft_content = draft_file.read_text()
|
||||
|
||||
# Should use actual heading text, not generic placeholders
|
||||
assert "# Project Requirements" in draft_content
|
||||
assert "## Overview" in draft_content
|
||||
assert "## Technical Specifications" in draft_content
|
||||
assert "## Implementation Plan" in draft_content
|
||||
assert "### Database Requirements" in draft_content
|
||||
assert "### API Requirements" in draft_content
|
||||
|
||||
# Should NOT have generic headings
|
||||
assert "## Introduction" not in draft_content
|
||||
assert "## Main Content" not in draft_content
|
||||
assert "## Section 1" not in draft_content
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
if schema_file.exists():
|
||||
schema_file.unlink()
|
||||
if draft_file.exists():
|
||||
draft_file.unlink()
|
||||
|
||||
def test_outline_schema_integration_with_content_instructions(self):
|
||||
"""Test that outline schemas integrate properly with content instructions."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate schema with both outline mode and content instructions
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--include-content-instructions',
|
||||
'--depth', '2',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
# Assert - Command should succeed
|
||||
assert result.exit_code == 0, f"Command failed: {result.output}"
|
||||
|
||||
# Parse the generated schema
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Should have both heading text capture and content instructions
|
||||
assert schema.get('x-markitect-heading-text-capture') == True
|
||||
assert schema.get('x-markitect-content-instructions-enabled') == True
|
||||
|
||||
# Check that headings have both enum constraints and content instructions
|
||||
level_1_items = schema['properties']['headings']['properties']['level_1']['items']['properties']
|
||||
assert 'enum' in level_1_items['content']
|
||||
assert 'x-markitect-content-instructions' in level_1_items
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
|
||||
def test_depth_limiting_works_correctly(self):
|
||||
"""Test that depth parameter correctly limits heading levels in outline mode."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate schema with depth limit of 2
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--depth', '2',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
# Assert - Command should succeed
|
||||
assert result.exit_code == 0, f"Command failed: {result.output}"
|
||||
|
||||
# Parse the generated schema
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Should have level 1 and 2 headings
|
||||
headings = schema['properties']['headings']['properties']
|
||||
assert 'level_1' in headings
|
||||
assert 'level_2' in headings
|
||||
|
||||
# Should NOT have level 3 headings due to depth limit
|
||||
assert 'level_3' not in headings
|
||||
|
||||
# Verify outline depth is recorded
|
||||
assert schema.get('x-markitect-outline-depth') == 2
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
|
||||
def test_outline_mode_title_format_correction(self):
|
||||
"""Test that outline mode generates correct title format."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate schema in outline mode
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0, f"Command failed: {result.output}"
|
||||
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Should use "Schema from" not "Schema for"
|
||||
expected_title = f"Schema from {md_file.name}"
|
||||
assert schema['title'] == expected_title
|
||||
|
||||
# Should have outline mode marker
|
||||
assert schema.get('x-markitect-outline-mode') == True
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
|
||||
def test_end_to_end_outline_workflow(self):
|
||||
"""Test complete workflow: example -> outline schema -> draft -> validation."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
example_file = Path(f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as draft_f:
|
||||
draft_file = Path(draft_f.name)
|
||||
|
||||
try:
|
||||
# Step 1: Generate outline schema from example
|
||||
schema_result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--include-content-instructions',
|
||||
'--depth', '3',
|
||||
'--outfile', str(schema_file),
|
||||
str(example_file)
|
||||
])
|
||||
assert schema_result.exit_code == 0
|
||||
|
||||
# Step 2: Generate draft from schema
|
||||
draft_result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file),
|
||||
'--output', str(draft_file)
|
||||
])
|
||||
assert draft_result.exit_code == 0
|
||||
|
||||
# Step 3: Verify draft content quality
|
||||
# Note: Skip validation since outline mode schemas capture full structural
|
||||
# requirements but stubs generate minimal content. This is expected behavior.
|
||||
draft_content = draft_file.read_text()
|
||||
|
||||
# Should preserve the document structure from example
|
||||
assert "# Project Requirements" in draft_content
|
||||
assert "## Overview" in draft_content
|
||||
assert "## Technical Specifications" in draft_content
|
||||
assert "### Database Requirements" in draft_content
|
||||
assert "### API Requirements" in draft_content
|
||||
assert "## Implementation Plan" in draft_content
|
||||
|
||||
# Should have schema reference
|
||||
assert f"Generated from schema: {schema_file}" in draft_content
|
||||
|
||||
finally:
|
||||
example_file.unlink()
|
||||
if schema_file.exists():
|
||||
schema_file.unlink()
|
||||
if draft_file.exists():
|
||||
draft_file.unlink()
|
||||
|
||||
def test_outline_mode_backwards_compatibility(self):
|
||||
"""Test that outline mode maintains backwards compatibility."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Test both old and new parameter styles work
|
||||
old_style_result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--max-depth', '2',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
new_style_result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--depth', '2',
|
||||
str(md_file)
|
||||
])
|
||||
|
||||
# Both should work
|
||||
assert old_style_result.exit_code == 0
|
||||
assert new_style_result.exit_code == 0
|
||||
|
||||
# Should produce equivalent schemas
|
||||
old_schema = json.loads(old_style_result.output)
|
||||
new_schema = json.loads(new_style_result.output)
|
||||
|
||||
assert old_schema['title'] == new_schema['title']
|
||||
assert old_schema.get('x-markitect-outline-mode') == new_schema.get('x-markitect-outline-mode')
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
|
||||
def test_outline_schema_supports_data_driven_generation(self):
|
||||
"""Test that outline schemas work with data-driven draft generation."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(self.test_md_content)
|
||||
md_file = Path(f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
data_file = Path(data_f.name)
|
||||
# Create test data
|
||||
data_f.write(json.dumps([
|
||||
{"project": "Alpha", "version": "1.0"},
|
||||
{"project": "Beta", "version": "2.0"}
|
||||
]))
|
||||
data_f.flush()
|
||||
|
||||
try:
|
||||
# Generate outline schema
|
||||
schema_result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--capture-heading-text',
|
||||
'--depth', '2',
|
||||
'--outfile', str(schema_file),
|
||||
str(md_file)
|
||||
])
|
||||
assert schema_result.exit_code == 0
|
||||
|
||||
# Test data-driven generation (if implemented)
|
||||
# This tests integration with Issue #56
|
||||
draft_result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', '/tmp/outline_drafts'
|
||||
])
|
||||
|
||||
# Should work or gracefully indicate feature not implemented
|
||||
assert draft_result.exit_code == 0 or "not implemented" in draft_result.output.lower()
|
||||
|
||||
finally:
|
||||
md_file.unlink()
|
||||
if schema_file.exists():
|
||||
schema_file.unlink()
|
||||
if data_file.exists():
|
||||
data_file.unlink()
|
||||
@@ -1,346 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #50: Define metaschema for JSON schema structure
|
||||
|
||||
This test module defines comprehensive tests for the MarkiTect metaschema that extends
|
||||
standard JSON Schema with MarkiTect-specific features like heading text capture,
|
||||
content field instructions, and outline structure representation.
|
||||
|
||||
Following TDD8 methodology - these tests are written before implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from markitect.metaschema import MetaschemaValidator, MARKITECT_METASCHEMA_PATH
|
||||
|
||||
|
||||
class TestIssue50MetaschemaDefinition:
|
||||
"""Test suite for MarkiTect metaschema definition and validation."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.metaschema_validator = MetaschemaValidator()
|
||||
|
||||
def test_metaschema_file_exists_and_is_valid_json(self):
|
||||
"""Test that the metaschema JSON file exists and contains valid JSON."""
|
||||
# Arrange & Act
|
||||
metaschema_path = Path(MARKITECT_METASCHEMA_PATH)
|
||||
|
||||
# Assert
|
||||
assert metaschema_path.exists(), f"Metaschema file should exist at {MARKITECT_METASCHEMA_PATH}"
|
||||
|
||||
with open(metaschema_path) as f:
|
||||
metaschema = json.load(f)
|
||||
|
||||
assert isinstance(metaschema, dict), "Metaschema should be a valid JSON object"
|
||||
assert "$schema" in metaschema, "Metaschema should have $schema property"
|
||||
assert "type" in metaschema, "Metaschema should have type property"
|
||||
|
||||
def test_metaschema_extends_json_schema_draft_07(self):
|
||||
"""Test that metaschema properly extends JSON Schema Draft 07."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
|
||||
# Assert
|
||||
assert metaschema["$schema"] == "http://json-schema.org/draft-07/schema#"
|
||||
assert metaschema["type"] == "object"
|
||||
assert "allOf" in metaschema, "Should extend base JSON Schema using allOf"
|
||||
|
||||
# Should reference standard JSON Schema
|
||||
found_json_schema_ref = False
|
||||
for schema_ref in metaschema["allOf"]:
|
||||
if "$ref" in schema_ref and "json-schema.org" in schema_ref["$ref"]:
|
||||
found_json_schema_ref = True
|
||||
break
|
||||
assert found_json_schema_ref, "Should reference standard JSON Schema Draft 07"
|
||||
|
||||
def test_metaschema_supports_heading_text_capture(self):
|
||||
"""Test that metaschema supports heading text capture extensions."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
|
||||
# Assert - Check for MarkiTect-specific heading text properties
|
||||
markitect_properties = self._get_markitect_extensions(metaschema)
|
||||
|
||||
assert "x-markitect-heading-text" in markitect_properties, \
|
||||
"Should support x-markitect-heading-text property"
|
||||
|
||||
heading_text_schema = markitect_properties["x-markitect-heading-text"]
|
||||
assert heading_text_schema["type"] == "string"
|
||||
assert "description" in heading_text_schema
|
||||
assert "preserve actual heading text" in heading_text_schema["description"].lower()
|
||||
|
||||
def test_metaschema_supports_content_field_instructions(self):
|
||||
"""Test that metaschema supports content field instruction capabilities."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
|
||||
# Assert - Check for content instruction properties
|
||||
markitect_properties = self._get_markitect_extensions(metaschema)
|
||||
|
||||
assert "x-markitect-content-instructions" in markitect_properties, \
|
||||
"Should support x-markitect-content-instructions property"
|
||||
|
||||
instructions_schema = markitect_properties["x-markitect-content-instructions"]
|
||||
assert instructions_schema["type"] == "string"
|
||||
assert "description" in instructions_schema
|
||||
assert "content author" in instructions_schema["description"].lower()
|
||||
|
||||
def test_metaschema_supports_outline_structure_representation(self):
|
||||
"""Test that metaschema supports outline structure representation."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
|
||||
# Assert - Check for outline structure properties
|
||||
markitect_properties = self._get_markitect_extensions(metaschema)
|
||||
|
||||
assert "x-markitect-outline-mode" in markitect_properties, \
|
||||
"Should support x-markitect-outline-mode property"
|
||||
|
||||
outline_schema = markitect_properties["x-markitect-outline-mode"]
|
||||
assert outline_schema["type"] == "boolean"
|
||||
assert "description" in outline_schema
|
||||
|
||||
assert "x-markitect-outline-depth" in markitect_properties, \
|
||||
"Should support x-markitect-outline-depth property"
|
||||
|
||||
depth_schema = markitect_properties["x-markitect-outline-depth"]
|
||||
assert depth_schema["type"] == "integer"
|
||||
assert depth_schema["minimum"] == 1
|
||||
|
||||
def test_metaschema_validates_standard_json_schema(self):
|
||||
"""Test that metaschema accepts standard JSON Schema documents (backward compatibility)."""
|
||||
# Arrange
|
||||
standard_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Standard Schema",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
is_valid = self.metaschema_validator.validate_schema(standard_schema)
|
||||
assert is_valid, "Standard JSON Schema should be valid against metaschema"
|
||||
|
||||
def test_metaschema_validates_markitect_extended_schema(self):
|
||||
"""Test that metaschema accepts MarkiTect extended schemas."""
|
||||
# Arrange
|
||||
markitect_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "MarkiTect Extended Schema",
|
||||
"x-markitect-outline-mode": True,
|
||||
"x-markitect-outline-depth": 3,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-heading-text": "Introduction",
|
||||
"x-markitect-content-instructions": "Provide overview of the document"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
is_valid = self.metaschema_validator.validate_schema(markitect_schema)
|
||||
assert is_valid, "MarkiTect extended schema should be valid against metaschema"
|
||||
|
||||
def test_metaschema_rejects_invalid_markitect_extensions(self):
|
||||
"""Test that metaschema rejects invalid MarkiTect extension values."""
|
||||
# Arrange
|
||||
invalid_schemas = [
|
||||
# Invalid outline depth (negative)
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"x-markitect-outline-depth": -1
|
||||
},
|
||||
# Invalid outline mode (not boolean)
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"x-markitect-outline-mode": "true"
|
||||
},
|
||||
# Invalid heading text (not string)
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heading": {
|
||||
"x-markitect-heading-text": 123
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for invalid_schema in invalid_schemas:
|
||||
is_valid = self.metaschema_validator.validate_schema(invalid_schema)
|
||||
assert not is_valid, f"Invalid schema should be rejected: {invalid_schema}"
|
||||
|
||||
def test_metaschema_validation_provides_detailed_errors(self):
|
||||
"""Test that metaschema validation provides detailed error messages."""
|
||||
# Arrange
|
||||
invalid_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"x-markitect-outline-depth": "not-a-number"
|
||||
}
|
||||
|
||||
# Act
|
||||
validation_result = self.metaschema_validator.validate_schema_with_errors(invalid_schema)
|
||||
|
||||
# Assert
|
||||
assert not validation_result.is_valid
|
||||
assert len(validation_result.errors) > 0
|
||||
|
||||
error_messages = [error.message for error in validation_result.errors]
|
||||
assert any("x-markitect-outline-depth" in msg for msg in error_messages), \
|
||||
"Error should mention the problematic MarkiTect extension"
|
||||
|
||||
def test_metaschema_supports_content_instruction_types(self):
|
||||
"""Test that metaschema supports different types of content instructions."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
markitect_properties = self._get_markitect_extensions(metaschema)
|
||||
|
||||
# Assert - Check for instruction type support
|
||||
assert "x-markitect-instruction-type" in markitect_properties, \
|
||||
"Should support x-markitect-instruction-type property"
|
||||
|
||||
instruction_type_schema = markitect_properties["x-markitect-instruction-type"]
|
||||
assert instruction_type_schema["type"] == "string"
|
||||
assert "enum" in instruction_type_schema
|
||||
|
||||
expected_types = ["description", "example", "constraint", "template"]
|
||||
for instruction_type in expected_types:
|
||||
assert instruction_type in instruction_type_schema["enum"], \
|
||||
f"Should support {instruction_type} instruction type"
|
||||
|
||||
def test_metaschema_supports_schema_metadata(self):
|
||||
"""Test that metaschema supports MarkiTect-specific schema metadata."""
|
||||
# Arrange & Act
|
||||
metaschema = self.metaschema_validator.get_metaschema()
|
||||
markitect_properties = self._get_markitect_extensions(metaschema)
|
||||
|
||||
# Assert - Check for metadata properties
|
||||
assert "x-markitect-generated-from" in markitect_properties, \
|
||||
"Should support x-markitect-generated-from property"
|
||||
|
||||
assert "x-markitect-generation-mode" in markitect_properties, \
|
||||
"Should support x-markitect-generation-mode property"
|
||||
|
||||
generation_mode = markitect_properties["x-markitect-generation-mode"]
|
||||
assert "enum" in generation_mode
|
||||
assert "outline" in generation_mode["enum"]
|
||||
assert "full" in generation_mode["enum"]
|
||||
|
||||
def test_existing_schemas_validate_against_metaschema(self):
|
||||
"""Test that all existing MarkiTect schemas validate against the new metaschema."""
|
||||
# Arrange - Get sample existing schema structure
|
||||
existing_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Schema for example.md",
|
||||
"description": "JSON schema describing the structure of example.md",
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"level": {"type": "integer"},
|
||||
"position": {"type": "integer"}
|
||||
},
|
||||
"required": ["content", "level"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"total_elements": {"type": "integer"},
|
||||
"structure_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
is_valid = self.metaschema_validator.validate_schema(existing_schema)
|
||||
assert is_valid, "Existing MarkiTect schemas should remain valid (backward compatibility)"
|
||||
|
||||
def _get_markitect_extensions(self, metaschema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Helper to extract MarkiTect extension properties from metaschema."""
|
||||
# Look for MarkiTect extensions in the allOf extension
|
||||
for extension in metaschema.get("allOf", []):
|
||||
if "properties" in extension:
|
||||
markitect_props = {}
|
||||
for prop_name, prop_schema in extension["properties"].items():
|
||||
if prop_name.startswith("x-markitect-"):
|
||||
markitect_props[prop_name] = prop_schema
|
||||
if markitect_props:
|
||||
return markitect_props
|
||||
|
||||
# Fallback: look in patternProperties for x-markitect- patterns
|
||||
pattern_props = metaschema.get("patternProperties", {})
|
||||
for pattern, schema in pattern_props.items():
|
||||
if "x-markitect" in pattern:
|
||||
return {pattern: schema}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class TestMetaschemaValidator:
|
||||
"""Test the MetaschemaValidator utility class."""
|
||||
|
||||
def test_metaschema_validator_can_be_created(self):
|
||||
"""Test that MetaschemaValidator can be instantiated."""
|
||||
# Act & Assert
|
||||
validator = MetaschemaValidator()
|
||||
assert validator is not None
|
||||
|
||||
def test_metaschema_validator_loads_metaschema(self):
|
||||
"""Test that MetaschemaValidator properly loads the metaschema."""
|
||||
# Arrange & Act
|
||||
validator = MetaschemaValidator()
|
||||
metaschema = validator.get_metaschema()
|
||||
|
||||
# Assert
|
||||
assert isinstance(metaschema, dict)
|
||||
assert "$schema" in metaschema
|
||||
assert metaschema["$schema"] == "http://json-schema.org/draft-07/schema#"
|
||||
|
||||
def test_metaschema_validator_caches_metaschema(self):
|
||||
"""Test that MetaschemaValidator caches the loaded metaschema."""
|
||||
# Arrange & Act
|
||||
validator = MetaschemaValidator()
|
||||
metaschema1 = validator.get_metaschema()
|
||||
metaschema2 = validator.get_metaschema()
|
||||
|
||||
# Assert
|
||||
assert metaschema1 is metaschema2, "Should cache metaschema instance"
|
||||
@@ -1,515 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #54: Add content field instruction capabilities
|
||||
|
||||
This test module implements comprehensive tests for content field instructions
|
||||
that provide guidance for content authors during document generation.
|
||||
|
||||
Following TDD8 methodology - these tests are written before implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.cli import cli
|
||||
from markitect.schema_generator import SchemaGenerator
|
||||
from markitect.stub_generator import StubGenerator
|
||||
from markitect.exceptions import InvalidInstructionTypeError
|
||||
|
||||
|
||||
class TestIssue54ContentInstructions:
|
||||
"""Test suite for content field instruction functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.schema_generator = SchemaGenerator()
|
||||
self.stub_generator = StubGenerator()
|
||||
self.runner = CliRunner()
|
||||
|
||||
def test_cli_accepts_include_content_instructions_option(self):
|
||||
"""Test that CLI accepts --include-content-instructions option."""
|
||||
# Arrange
|
||||
markdown_content = """# Architecture Document
|
||||
|
||||
## Introduction
|
||||
This section provides an overview of the system.
|
||||
|
||||
## Design Principles
|
||||
Core principles guiding the design.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--include-content-instructions',
|
||||
str(temp_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0, f"CLI should accept --include-content-instructions option, got: {result.output}"
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_schema_generation_with_content_instructions_includes_instruction_fields(self):
|
||||
"""Test that schema generation with content instructions includes instruction fields."""
|
||||
# Arrange
|
||||
markdown_content = """# Software Architecture Document
|
||||
|
||||
## Introduction
|
||||
This section provides an overview of the system architecture.
|
||||
|
||||
### Purpose
|
||||
Explain the purpose and goals of this document.
|
||||
|
||||
## System Design
|
||||
Describe the overall system design and architecture.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
include_content_instructions=True
|
||||
)
|
||||
|
||||
# Assert - Schema should contain content instruction fields
|
||||
assert "properties" in schema
|
||||
assert "headings" in schema["properties"]
|
||||
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
|
||||
# Level 1 heading should have content instructions
|
||||
level_1 = headings["level_1"]
|
||||
items_props = level_1["items"]["properties"]
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
assert items_props["x-markitect-content-instructions"]["type"] == "string"
|
||||
|
||||
# Level 2 headings should have content instructions
|
||||
level_2 = headings["level_2"]
|
||||
items_props = level_2["items"]["properties"]
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_schema_includes_content_instruction_metaschema_extension(self):
|
||||
"""Test that schemas with content instructions include metaschema extension."""
|
||||
# Arrange
|
||||
markdown_content = """# Test Document
|
||||
|
||||
## Section A
|
||||
Content for section A.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
include_content_instructions=True
|
||||
)
|
||||
|
||||
# Assert - Should have metaschema extension
|
||||
assert "x-markitect-content-instructions-enabled" in schema
|
||||
assert schema["x-markitect-content-instructions-enabled"] is True
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_content_instructions_support_different_instruction_types(self):
|
||||
"""Test that content instructions can specify different instruction types."""
|
||||
# Arrange
|
||||
markdown_content = """# Requirements Document
|
||||
|
||||
## Functional Requirements
|
||||
List all functional requirements.
|
||||
|
||||
## Non-Functional Requirements
|
||||
Describe performance, security, and usability requirements.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
include_content_instructions=True,
|
||||
instruction_type="description"
|
||||
)
|
||||
|
||||
# Assert - Should have instruction type specified
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
level_2 = headings["level_2"]
|
||||
items_props = level_2["items"]["properties"]
|
||||
|
||||
assert "x-markitect-instruction-type" in items_props
|
||||
assert items_props["x-markitect-instruction-type"]["enum"] == ["description"]
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_content_instructions_integration_with_outline_mode(self):
|
||||
"""Test that content instructions work with outline mode."""
|
||||
# Arrange
|
||||
markdown_content = """# Project Plan
|
||||
|
||||
## Phase 1: Planning
|
||||
Planning activities and deliverables.
|
||||
|
||||
### Requirements Gathering
|
||||
Gather and document all requirements.
|
||||
|
||||
### Architecture Design
|
||||
Design the system architecture.
|
||||
|
||||
## Phase 2: Implementation
|
||||
Implementation activities.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--mode', 'outline',
|
||||
'--include-content-instructions',
|
||||
'--depth', '2',
|
||||
str(temp_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Should have both outline mode and content instructions extensions
|
||||
assert schema.get("x-markitect-outline-mode") is True
|
||||
assert schema.get("x-markitect-content-instructions-enabled") is True
|
||||
|
||||
# Should only include headings up to depth 2
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
assert "level_1" in headings
|
||||
assert "level_2" in headings
|
||||
assert "level_3" not in headings
|
||||
|
||||
# Should have content instructions in the headings
|
||||
level_2 = headings["level_2"]
|
||||
items_props = level_2["items"]["properties"]
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_content_instructions_for_paragraphs_and_lists(self):
|
||||
"""Test that content instructions can be added for paragraphs and lists."""
|
||||
# Arrange
|
||||
markdown_content = """# User Guide
|
||||
|
||||
## Overview
|
||||
This guide explains how to use the system.
|
||||
|
||||
Some introductory text here.
|
||||
|
||||
- Feature A
|
||||
- Feature B
|
||||
- Feature C
|
||||
|
||||
## Getting Started
|
||||
Follow these steps to get started.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
include_content_instructions=True
|
||||
)
|
||||
|
||||
# Assert - Paragraphs should have content instructions
|
||||
if "paragraphs" in schema["properties"]:
|
||||
paragraphs_schema = schema["properties"]["paragraphs"]
|
||||
items_props = paragraphs_schema["items"]["properties"]
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
|
||||
# Lists should have content instructions
|
||||
if "lists" in schema["properties"]:
|
||||
lists_schema = schema["properties"]["lists"]
|
||||
items_props = lists_schema["items"]["properties"]
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_stub_generation_includes_content_instruction_placeholders(self):
|
||||
"""Test that stub generation includes content instruction placeholders."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Test Schema",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Provide the main title of the document"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"level_2": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Describe each major section of the document"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Act
|
||||
stub_content = self.stub_generator.generate_stub_from_schema(schema)
|
||||
|
||||
# Assert - Stub should include instruction placeholders
|
||||
assert "Provide the main title of the document" in stub_content
|
||||
assert "Describe each major section of the document" in stub_content
|
||||
|
||||
def test_cli_supports_instruction_type_parameter(self):
|
||||
"""Test that CLI supports --instruction-type parameter."""
|
||||
# Arrange
|
||||
markdown_content = """# API Documentation
|
||||
|
||||
## Authentication
|
||||
How to authenticate with the API.
|
||||
|
||||
## Endpoints
|
||||
Available API endpoints.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--include-content-instructions',
|
||||
'--instruction-type', 'example',
|
||||
str(temp_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
schema = json.loads(result.output)
|
||||
|
||||
# Check that instruction type is set correctly
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
level_1 = headings["level_1"]
|
||||
items_props = level_1["items"]["properties"]
|
||||
assert items_props["x-markitect-instruction-type"]["enum"] == ["example"]
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_backward_compatibility_without_content_instructions(self):
|
||||
"""Test that existing behavior is maintained when content instructions are not enabled."""
|
||||
# Arrange
|
||||
markdown_content = """# Test Document
|
||||
|
||||
## Section One
|
||||
Content here.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate schema without content instructions (default behavior)
|
||||
schema = self.schema_generator.generate_schema_from_file(temp_file)
|
||||
|
||||
# Assert - Should NOT have content instruction fields
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
level_1 = headings["level_1"]
|
||||
items_props = level_1["items"]["properties"]
|
||||
|
||||
# Should not have content instruction fields
|
||||
assert "x-markitect-content-instructions" not in items_props
|
||||
assert "x-markitect-instruction-type" not in items_props
|
||||
|
||||
# Should NOT have content instructions extension
|
||||
assert "x-markitect-content-instructions-enabled" not in schema
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_content_instructions_with_heading_text_capture_integration(self):
|
||||
"""Test that content instructions work with heading text capture."""
|
||||
# Arrange
|
||||
markdown_content = """# Architecture Overview
|
||||
|
||||
## System Components
|
||||
Core system components and their responsibilities.
|
||||
|
||||
## Data Flow
|
||||
How data flows through the system.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
capture_heading_text=True,
|
||||
include_content_instructions=True
|
||||
)
|
||||
|
||||
# Assert - Should have both heading text capture and content instructions
|
||||
assert schema.get("x-markitect-heading-text-capture") is True
|
||||
assert schema.get("x-markitect-content-instructions-enabled") is True
|
||||
|
||||
# Headings should have both enum constraints and content instructions
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
level_1 = headings["level_1"]
|
||||
items_props = level_1["items"]["properties"]
|
||||
|
||||
# Should have enum constraint from heading text capture
|
||||
assert "enum" in items_props["content"]
|
||||
assert items_props["content"]["enum"] == ["Architecture Overview"]
|
||||
|
||||
# Should also have content instructions
|
||||
assert "x-markitect-content-instructions" in items_props
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_cli_help_includes_content_instructions_options(self):
|
||||
"""Test that CLI help includes documentation for content instruction options."""
|
||||
# Act
|
||||
result = self.runner.invoke(cli, ['schema-generate', '--help'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
help_text = result.output
|
||||
assert "--include-content-instructions" in help_text
|
||||
assert "--instruction-type" in help_text
|
||||
assert "content instructions" in help_text or "content guidance" in help_text
|
||||
|
||||
def test_instruction_type_validation(self):
|
||||
"""Test that --instruction-type parameter validates input correctly."""
|
||||
# Arrange
|
||||
markdown_content = """# Test Document
|
||||
|
||||
## Section
|
||||
Content here.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Test invalid instruction type
|
||||
result = self.runner.invoke(cli, [
|
||||
'schema-generate',
|
||||
'--include-content-instructions',
|
||||
'--instruction-type', 'invalid-type',
|
||||
str(temp_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid instruction type" in result.output or "invalid-type" in result.output
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
|
||||
def test_content_instructions_generate_appropriate_default_text(self):
|
||||
"""Test that content instructions generate appropriate default guidance text."""
|
||||
# Arrange
|
||||
markdown_content = """# Development Guide
|
||||
|
||||
## Prerequisites
|
||||
System requirements and prerequisites.
|
||||
|
||||
### Software Requirements
|
||||
Required software and versions.
|
||||
|
||||
## Installation
|
||||
Step-by-step installation instructions.
|
||||
|
||||
## Configuration
|
||||
How to configure the system.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(markdown_content)
|
||||
temp_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
schema = self.schema_generator.generate_schema_from_file(
|
||||
temp_file,
|
||||
include_content_instructions=True,
|
||||
instruction_type="description"
|
||||
)
|
||||
|
||||
# Assert - Content instructions should contain appropriate guidance
|
||||
headings = schema["properties"]["headings"]["properties"]
|
||||
|
||||
# Level 1 should have appropriate instructions
|
||||
level_1 = headings["level_1"]
|
||||
items_props = level_1["items"]["properties"]
|
||||
instructions = items_props["x-markitect-content-instructions"]["const"]
|
||||
assert len(instructions) > 0
|
||||
assert isinstance(instructions, str)
|
||||
|
||||
# Instructions should be contextually appropriate
|
||||
# (implementation will determine specific text)
|
||||
|
||||
finally:
|
||||
temp_file.unlink()
|
||||
@@ -1,632 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #55: Schema-based draft generation
|
||||
|
||||
This test module implements comprehensive tests for enhanced schema-based draft
|
||||
generation that utilizes content field instructions and schema metadata.
|
||||
|
||||
Following TDD8 methodology - these tests are written before implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.cli import cli
|
||||
from markitect.stub_generator import StubGenerator
|
||||
|
||||
|
||||
class TestIssue55SchemaBasedDraftGeneration:
|
||||
"""Test suite for enhanced schema-based draft generation functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.stub_generator = StubGenerator()
|
||||
self.runner = CliRunner()
|
||||
|
||||
def test_generate_stub_uses_content_instructions_from_schema(self):
|
||||
"""Test that generate-stub uses content instructions instead of generic placeholders."""
|
||||
# Arrange
|
||||
schema_with_instructions = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "API Documentation",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"level": {"type": "integer"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Write the main API documentation title"
|
||||
},
|
||||
"x-markitect-instruction-type": {
|
||||
"type": "string",
|
||||
"enum": ["description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
},
|
||||
"level_2": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"level": {"type": "integer"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Describe each API endpoint section"
|
||||
},
|
||||
"x-markitect-instruction-type": {
|
||||
"type": "string",
|
||||
"enum": ["description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema_with_instructions, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should use specific content instructions, not generic placeholders
|
||||
assert "Write the main API documentation title" in output
|
||||
assert "Describe each API endpoint section" in output
|
||||
|
||||
# Should NOT contain generic placeholder text
|
||||
assert "TODO: Add content for" not in output
|
||||
assert "section_level_2 section" not in output
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_includes_schema_reference_metadata(self):
|
||||
"""Test that generated drafts include reference to their source schema."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Requirements Document",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Provide the document title"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should include schema reference metadata
|
||||
assert "<!-- Generated from schema:" in output or "Source schema:" in output
|
||||
assert str(schema_file.name) in output or schema_file.name in output
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_supports_different_instruction_types(self):
|
||||
"""Test that generate-stub handles different instruction types appropriately."""
|
||||
# Arrange
|
||||
schema_with_example_instructions = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Tutorial Guide",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Example: Getting Started with Our API"
|
||||
},
|
||||
"x-markitect-instruction-type": {
|
||||
"type": "string",
|
||||
"enum": ["example"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema_with_example_instructions, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should use the example-type instruction
|
||||
assert "Example: Getting Started with Our API" in output
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_handles_schemas_without_content_instructions(self):
|
||||
"""Test that generate-stub gracefully handles schemas without content instructions."""
|
||||
# Arrange - Schema without content instructions (backward compatibility)
|
||||
schema_without_instructions = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Basic Document",
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"level": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema_without_instructions, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert - Should still work with generic placeholders
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should fall back to generic placeholder behavior
|
||||
assert "Basic Document" in output # Should use schema title
|
||||
assert len(output) > 0 # Should generate some content
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_supports_output_file_with_schema_reference(self):
|
||||
"""Test that generate-stub writes to output file with schema reference."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Project Plan",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Provide the project name and overview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
output_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file),
|
||||
'--output', str(output_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert output_file.exists()
|
||||
|
||||
content = output_file.read_text()
|
||||
assert "Provide the project name and overview" in content
|
||||
assert "Project Plan" in content
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
if output_file.exists():
|
||||
output_file.unlink()
|
||||
|
||||
def test_generate_stub_validates_generated_draft_against_schema(self):
|
||||
"""Test that generated drafts can be validated against their source schema."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Meeting Notes",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Meeting title and date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
draft_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act - Generate draft
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file),
|
||||
'--output', str(draft_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Act - Validate generated draft against schema
|
||||
validate_result = self.runner.invoke(cli, [
|
||||
'validate',
|
||||
str(draft_file),
|
||||
'--schema', str(schema_file)
|
||||
])
|
||||
|
||||
# Assert - Generated draft should be valid against its source schema
|
||||
assert validate_result.exit_code == 0
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
if draft_file.exists():
|
||||
draft_file.unlink()
|
||||
|
||||
def test_stub_generator_class_supports_content_instructions_directly(self):
|
||||
"""Test that StubGenerator class can be used directly with content instruction schemas."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Architecture Document",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "System architecture overview title"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Act
|
||||
stub_content = self.stub_generator.generate_stub_from_schema(schema)
|
||||
|
||||
# Assert
|
||||
assert "System architecture overview title" in stub_content
|
||||
assert "Architecture Document" in stub_content
|
||||
|
||||
def test_generate_stub_with_outline_mode_schema_integration(self):
|
||||
"""Test that generate-stub works with schemas created by outline mode + content instructions."""
|
||||
# Arrange - Schema that would be generated by outline mode with content instructions
|
||||
outline_schema_with_instructions = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Schema from user_guide.md",
|
||||
"x-markitect-outline-mode": True,
|
||||
"x-markitect-outline-depth": 2,
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Provide content for the 'level 1 heading' section"
|
||||
},
|
||||
"x-markitect-instruction-type": {
|
||||
"type": "string",
|
||||
"enum": ["description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
},
|
||||
"level_2": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Provide content for the 'level 2 heading' section"
|
||||
},
|
||||
"x-markitect-instruction-type": {
|
||||
"type": "string",
|
||||
"enum": ["description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(outline_schema_with_instructions, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should use content instructions from outline mode schema
|
||||
assert "Provide content for the 'level 1 heading' section" in output
|
||||
assert "Provide content for the 'level 2 heading' section" in output
|
||||
|
||||
# Should respect outline mode structure (depth limited to 2)
|
||||
assert output.count('##') >= 3 # Should have multiple level 2 headings
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_with_heading_text_capture_schema_integration(self):
|
||||
"""Test that generate-stub works with schemas that have heading text capture."""
|
||||
# Arrange - Schema with both heading text capture and content instructions
|
||||
schema_with_heading_capture = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Schema from api_docs.md",
|
||||
"x-markitect-heading-text-capture": True,
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"enum": ["API Reference"] # From heading text capture
|
||||
},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Complete API reference documentation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema_with_heading_capture, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should use the specific heading text from enum constraint
|
||||
assert "# API Reference" in output
|
||||
|
||||
# Should use content instructions
|
||||
assert "Complete API reference documentation" in output
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
|
||||
def test_generate_stub_preserves_schema_metadata_in_output(self):
|
||||
"""Test that important schema metadata is preserved in the generated draft."""
|
||||
# Arrange
|
||||
schema_with_metadata = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Design Document",
|
||||
"description": "Template for system design documents",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"x-markitect-outline-mode": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Design document title and scope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump(schema_with_metadata, f, indent=2)
|
||||
schema_file = Path(f.name)
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
|
||||
# Should include schema metadata information
|
||||
assert any(marker in output.lower() for marker in [
|
||||
"generated from", "source schema", "template for", "schema:", "outline mode"
|
||||
])
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
@@ -1,736 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #56: Data-driven multiple draft generation
|
||||
|
||||
This test module implements comprehensive tests for data-driven draft generation
|
||||
that creates multiple documents from a schema and data source with field mapping.
|
||||
|
||||
Following TDD8 methodology - these tests are written before implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import csv
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.cli import cli
|
||||
|
||||
|
||||
class TestIssue56DataDrivenDraftGeneration:
|
||||
"""Test suite for data-driven multiple draft generation functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.runner = CliRunner()
|
||||
|
||||
def test_cli_has_generate_drafts_command(self):
|
||||
"""Test that CLI has a generate-drafts command for data-driven generation."""
|
||||
# Act
|
||||
result = self.runner.invoke(cli, ['--help'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert 'generate-drafts' in result.output, "CLI should have generate-drafts command"
|
||||
|
||||
def test_generate_drafts_command_help(self):
|
||||
"""Test that generate-drafts command has proper help documentation."""
|
||||
# Act
|
||||
result = self.runner.invoke(cli, ['generate-drafts', '--help'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
help_text = result.output.lower()
|
||||
assert 'data source' in help_text
|
||||
assert 'schema' in help_text
|
||||
assert 'multiple' in help_text or 'batch' in help_text
|
||||
|
||||
def test_generate_drafts_supports_json_data_source(self):
|
||||
"""Test that generate-drafts supports JSON data sources."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Employee Profile",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Employee name: {name}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
},
|
||||
"level_2": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Role: {role}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "role"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [
|
||||
{"name": "Alice Johnson", "role": "Software Engineer", "department": "Engineering"},
|
||||
{"name": "Bob Smith", "role": "Product Manager", "department": "Product"}
|
||||
]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0, f"Command should succeed, got: {result.output}"
|
||||
|
||||
# Check that multiple files were generated
|
||||
output_path = Path(output_dir)
|
||||
generated_files = list(output_path.glob('*.md'))
|
||||
assert len(generated_files) >= 2, "Should generate multiple draft files"
|
||||
|
||||
# Check content of generated files
|
||||
for file in generated_files:
|
||||
content = file.read_text()
|
||||
# Should contain mapped data
|
||||
assert any(name in content for name in ["Alice Johnson", "Bob Smith"])
|
||||
assert any(role in content for role in ["Software Engineer", "Product Manager"])
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_supports_csv_data_source(self):
|
||||
"""Test that generate-drafts supports CSV data sources."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Product Description",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Product: {product_name}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "product_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as csv_f:
|
||||
writer = csv.writer(csv_f)
|
||||
writer.writerow(['product_name', 'price', 'category'])
|
||||
writer.writerow(['Laptop Pro', '1299.99', 'Electronics'])
|
||||
writer.writerow(['Office Chair', '249.99', 'Furniture'])
|
||||
csv_file = Path(csv_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(csv_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0, f"CSV processing should work, got: {result.output}"
|
||||
|
||||
# Check generated files
|
||||
output_path = Path(output_dir)
|
||||
generated_files = list(output_path.glob('*.md'))
|
||||
assert len(generated_files) >= 2, "Should generate files for each CSV row"
|
||||
|
||||
# Check content contains mapped CSV data
|
||||
all_content = ""
|
||||
for file in generated_files:
|
||||
all_content += file.read_text()
|
||||
|
||||
assert "Laptop Pro" in all_content
|
||||
assert "Office Chair" in all_content
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
csv_file.unlink()
|
||||
|
||||
def test_generate_drafts_field_mapping_functionality(self):
|
||||
"""Test that field mapping works correctly between data and schema."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Blog Post",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "{title}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"level_2": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Author: {author_name}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "author_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [
|
||||
{"title": "Getting Started with Python", "author_name": "Jane Doe", "tags": ["python", "beginner"]},
|
||||
{"title": "Advanced JavaScript Patterns", "author_name": "John Smith", "tags": ["javascript", "advanced"]}
|
||||
]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify field mapping worked correctly
|
||||
generated_files = list(Path(output_dir).glob('*.md'))
|
||||
assert len(generated_files) == 2
|
||||
|
||||
contents = [file.read_text() for file in generated_files]
|
||||
|
||||
# Check that titles and authors are properly mapped
|
||||
assert any("Getting Started with Python" in content for content in contents)
|
||||
assert any("Advanced JavaScript Patterns" in content for content in contents)
|
||||
assert any("Author: Jane Doe" in content for content in contents)
|
||||
assert any("Author: John Smith" in content for content in contents)
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_maintains_schema_references(self):
|
||||
"""Test that generated drafts maintain schema references for validation."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Meeting Notes",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Meeting: {meeting_title}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "meeting_title"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [{"meeting_title": "Weekly Standup", "date": "2024-01-15"}]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check schema reference is maintained
|
||||
generated_files = list(Path(output_dir).glob('*.md'))
|
||||
assert len(generated_files) >= 1
|
||||
|
||||
for file in generated_files:
|
||||
content = file.read_text()
|
||||
assert f"Generated from schema: {schema_file}" in content
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_output_directory_specification(self):
|
||||
"""Test that CLI supports output directory specification for batch generation."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Test Document",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "{name}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [{"name": "Test1"}, {"name": "Test2"}]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
output_dir = Path(temp_dir) / "custom_output"
|
||||
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', str(output_dir)
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert output_dir.exists(), "Output directory should be created"
|
||||
|
||||
generated_files = list(output_dir.glob('*.md'))
|
||||
assert len(generated_files) >= 2, "Should generate files in specified directory"
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_data_validation_compatibility(self):
|
||||
"""Test that data validation ensures compatibility with schema requirements."""
|
||||
# Arrange - Schema requires specific fields
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Validated Document",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Required field: {required_field}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "required_field"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-markitect-required-fields": ["required_field"]
|
||||
}
|
||||
|
||||
# Data missing required field
|
||||
invalid_data = [{"optional_field": "value"}]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(invalid_data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert - Should fail validation or provide warning
|
||||
# Could be exit code != 0 or warning in output
|
||||
assert result.exit_code != 0 or "warning" in result.output.lower() or "missing" in result.output.lower()
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_error_handling_data_schema_mismatch(self):
|
||||
"""Test error handling for data-schema mismatches."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Test Schema",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Data with different field names
|
||||
mismatched_data = [{"different_field": "value"}]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(mismatched_data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert - Should handle mismatch gracefully
|
||||
# Either succeed with warnings or fail with clear error
|
||||
if result.exit_code != 0:
|
||||
assert len(result.output) > 0 # Should have error message
|
||||
else:
|
||||
# If succeeded, should have warnings or default handling
|
||||
assert "warning" in result.output.lower() or len(list(Path(output_dir).glob('*.md'))) > 0
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_file_naming_convention(self):
|
||||
"""Test that generated files follow a consistent naming convention."""
|
||||
# Arrange
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Item Description",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Item: {id}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [
|
||||
{"id": "item-001", "name": "First Item"},
|
||||
{"id": "item-002", "name": "Second Item"}
|
||||
]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with TemporaryDirectory() as output_dir:
|
||||
try:
|
||||
# Act
|
||||
result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
|
||||
generated_files = list(Path(output_dir).glob('*.md'))
|
||||
assert len(generated_files) == 2
|
||||
|
||||
# Check naming convention
|
||||
filenames = [f.name for f in generated_files]
|
||||
for filename in filenames:
|
||||
assert filename.endswith('.md')
|
||||
# Should contain identifier or be sequentially named
|
||||
assert len(filename) > 3 # At least "x.md"
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
|
||||
def test_generate_drafts_integration_with_existing_stub_generation(self):
|
||||
"""Test that generate-drafts integrates properly with existing stub generation from Issue #55."""
|
||||
# Arrange - Use schema that works with single draft generation
|
||||
schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Integration Test",
|
||||
"x-markitect-content-instructions-enabled": True,
|
||||
"properties": {
|
||||
"headings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level_1": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"x-markitect-content-instructions": {
|
||||
"type": "string",
|
||||
"const": "Title: {title}"
|
||||
},
|
||||
"x-markitect-field-mapping": {
|
||||
"type": "string",
|
||||
"const": "title"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = [{"title": "Test Document"}]
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as schema_f:
|
||||
json.dump(schema, schema_f, indent=2)
|
||||
schema_file = Path(schema_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.json', delete=False) as data_f:
|
||||
json.dump(data, data_f, indent=2)
|
||||
data_file = Path(data_f.name)
|
||||
|
||||
with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as single_output_f:
|
||||
single_output_file = Path(single_output_f.name)
|
||||
|
||||
with TemporaryDirectory() as batch_output_dir:
|
||||
try:
|
||||
# Act - Test both single and batch generation
|
||||
single_result = self.runner.invoke(cli, [
|
||||
'generate-stub',
|
||||
str(schema_file),
|
||||
'--output', str(single_output_file)
|
||||
])
|
||||
|
||||
batch_result = self.runner.invoke(cli, [
|
||||
'generate-drafts',
|
||||
str(schema_file),
|
||||
str(data_file),
|
||||
'--output-dir', batch_output_dir
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert single_result.exit_code == 0
|
||||
assert batch_result.exit_code == 0
|
||||
|
||||
# Check single output
|
||||
single_content = single_output_file.read_text()
|
||||
assert "Integration Test" in single_content
|
||||
|
||||
# Check batch output
|
||||
batch_files = list(Path(batch_output_dir).glob('*.md'))
|
||||
assert len(batch_files) >= 1
|
||||
|
||||
batch_content = batch_files[0].read_text()
|
||||
assert "Test Document" in batch_content
|
||||
|
||||
# Both should have schema references
|
||||
assert "Generated from schema:" in single_content
|
||||
assert "Generated from schema:" in batch_content
|
||||
|
||||
finally:
|
||||
schema_file.unlink()
|
||||
data_file.unlink()
|
||||
if single_output_file.exists():
|
||||
single_output_file.unlink()
|
||||
@@ -1,627 +0,0 @@
|
||||
"""
|
||||
Tests for Issue #83: Full text search functionality.
|
||||
|
||||
Tests the FTS5-based full text search plugin including indexing,
|
||||
querying, and CLI integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from markitect.plugins.builtin.search import FTSSearchPlugin, SearchIndexer, QueryParser
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
|
||||
class TestSearchIndexer:
|
||||
"""Test the search indexing functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(self):
|
||||
"""Create a temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add test markdown files
|
||||
db_manager.store_markdown_file("test1.md", "# Test Document\n\nThis is a test document about API development.")
|
||||
db_manager.store_markdown_file("test2.md", "# Another Document\n\nGraphQL interface documentation.")
|
||||
db_manager.store_markdown_file("test3.md", "---\ntitle: Blog Post\n---\n# My Blog\n\nContent about technology.")
|
||||
|
||||
# Add test schemas
|
||||
schema1 = {"type": "object", "title": "User Schema", "description": "Schema for user objects"}
|
||||
schema2 = {"type": "object", "title": "Product Schema", "description": "E-commerce product definition"}
|
||||
db_manager.store_schema_file("user.json", json.dumps(schema1))
|
||||
db_manager.store_schema_file("product.json", json.dumps(schema2))
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_check_fts_availability(self, temp_db_path):
|
||||
"""Test checking FTS5 availability."""
|
||||
indexer = SearchIndexer()
|
||||
available = indexer.check_fts_availability(temp_db_path)
|
||||
|
||||
# FTS5 should be available in most modern SQLite installations
|
||||
assert isinstance(available, bool)
|
||||
|
||||
def test_initialize_fts_tables(self, temp_db_path):
|
||||
"""Test FTS5 table initialization."""
|
||||
indexer = SearchIndexer()
|
||||
indexer.initialize_fts_tables(temp_db_path)
|
||||
|
||||
# Check that FTS tables were created
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'fts_%'")
|
||||
fts_tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
if indexer.check_fts_availability(temp_db_path):
|
||||
assert 'fts_files' in fts_tables
|
||||
assert 'fts_schemas' in fts_tables
|
||||
else:
|
||||
# If FTS5 not available, should have status table
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='fts_status'")
|
||||
assert cursor.fetchone() is not None
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_rebuild_index(self, temp_db_path):
|
||||
"""Test rebuilding search indexes."""
|
||||
indexer = SearchIndexer()
|
||||
indexer.initialize_fts_tables(temp_db_path)
|
||||
|
||||
stats = indexer.rebuild_index(temp_db_path)
|
||||
|
||||
assert 'files_indexed' in stats
|
||||
assert 'schemas_indexed' in stats
|
||||
|
||||
if indexer.check_fts_availability(temp_db_path):
|
||||
# If FTS5 is available, should index successfully
|
||||
assert stats['files_indexed'] >= 0
|
||||
assert stats['schemas_indexed'] >= 0
|
||||
else:
|
||||
# If FTS5 not available, might have error
|
||||
pass # Just check stats exist
|
||||
|
||||
def test_get_index_info(self, temp_db_path):
|
||||
"""Test getting index information."""
|
||||
indexer = SearchIndexer()
|
||||
indexer.initialize_fts_tables(temp_db_path)
|
||||
indexer.rebuild_index(temp_db_path)
|
||||
|
||||
info = indexer.get_index_info(temp_db_path)
|
||||
|
||||
assert 'fts_enabled' in info
|
||||
if info['fts_enabled']:
|
||||
assert 'fts_tables' in info
|
||||
assert 'fts_files_count' in info
|
||||
assert 'fts_schemas_count' in info
|
||||
|
||||
|
||||
class TestQueryParser:
|
||||
"""Test query parsing functionality."""
|
||||
|
||||
def test_parse_simple_query(self):
|
||||
"""Test parsing simple queries."""
|
||||
parser = QueryParser()
|
||||
|
||||
# Simple word
|
||||
result = parser.parse_query("test")
|
||||
assert "test*" in result
|
||||
|
||||
# Multiple words
|
||||
result = parser.parse_query("test document")
|
||||
assert "test*" in result
|
||||
assert "document*" in result
|
||||
assert "AND" in result
|
||||
|
||||
def test_parse_phrase_query(self):
|
||||
"""Test parsing phrase queries."""
|
||||
parser = QueryParser()
|
||||
|
||||
result = parser.parse_query('"exact phrase"')
|
||||
assert '"exact phrase"' in result
|
||||
|
||||
def test_parse_boolean_operators(self):
|
||||
"""Test parsing boolean operators."""
|
||||
parser = QueryParser()
|
||||
|
||||
# AND operator - if already FTS5, should be preserved
|
||||
result = parser.parse_query("test AND document")
|
||||
assert "test" in result
|
||||
assert "AND" in result
|
||||
assert "document" in result
|
||||
|
||||
# OR operator - if already FTS5, should be preserved
|
||||
result = parser.parse_query("test OR document")
|
||||
assert "test" in result
|
||||
assert "OR" in result
|
||||
assert "document" in result
|
||||
|
||||
# NOT operator - if already FTS5, should be preserved
|
||||
result = parser.parse_query("test NOT document")
|
||||
assert "test" in result
|
||||
assert "NOT" in result
|
||||
|
||||
def test_validate_query(self):
|
||||
"""Test query validation."""
|
||||
parser = QueryParser()
|
||||
|
||||
# Valid queries
|
||||
valid, error = parser.validate_query("test")
|
||||
assert valid
|
||||
assert error is None
|
||||
|
||||
valid, error = parser.validate_query('"exact phrase"')
|
||||
assert valid
|
||||
assert error is None
|
||||
|
||||
# Invalid queries
|
||||
valid, error = parser.validate_query('unmatched "quote')
|
||||
assert not valid
|
||||
assert "quotes" in error
|
||||
|
||||
valid, error = parser.validate_query("test (unmatched")
|
||||
assert not valid
|
||||
assert "parentheses" in error
|
||||
|
||||
def test_get_query_terms(self):
|
||||
"""Test extracting terms from queries."""
|
||||
parser = QueryParser()
|
||||
|
||||
terms = parser.get_query_terms("test document AND api")
|
||||
assert "test" in terms
|
||||
assert "document" in terms
|
||||
assert "api" in terms
|
||||
assert "AND" not in terms # Operators should be excluded
|
||||
|
||||
def test_build_column_query(self):
|
||||
"""Test building column-specific queries."""
|
||||
parser = QueryParser()
|
||||
|
||||
result = parser.build_column_query("test", ["title", "content"])
|
||||
assert "title:" in result
|
||||
assert "content:" in result
|
||||
assert "OR" in result
|
||||
|
||||
|
||||
class TestFTSSearchPlugin:
|
||||
"""Test the main FTS search plugin."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(self):
|
||||
"""Create a temporary database with test data."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add test markdown files
|
||||
db_manager.store_markdown_file("api-guide.md", "# API Guide\n\nComprehensive API development guide with examples.")
|
||||
db_manager.store_markdown_file("tutorial.md", "# GraphQL Tutorial\n\nLearn GraphQL basics and advanced concepts.")
|
||||
db_manager.store_markdown_file("readme.md", "---\ntitle: Project README\ntags: [documentation, guide]\n---\n# Project\n\nProject documentation and setup guide.")
|
||||
|
||||
# Add test schemas
|
||||
schema1 = {"type": "object", "title": "API Schema", "description": "REST API response schema", "properties": {"data": {"type": "object"}}}
|
||||
schema2 = {"type": "object", "title": "User Schema", "description": "User profile schema", "properties": {"name": {"type": "string"}}}
|
||||
db_manager.store_schema_file("api-schema.json", json.dumps(schema1))
|
||||
db_manager.store_schema_file("user-schema.json", json.dumps(schema2))
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_plugin_metadata(self):
|
||||
"""Test plugin metadata."""
|
||||
plugin = FTSSearchPlugin()
|
||||
metadata = plugin.metadata
|
||||
|
||||
assert metadata.name == "fts_search"
|
||||
assert metadata.version == "1.0.0"
|
||||
assert "full text search" in metadata.description.lower()
|
||||
|
||||
def test_initialize_plugin(self, temp_db_path):
|
||||
"""Test plugin initialization."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
|
||||
# Check that FTS tables exist (if FTS5 is available)
|
||||
stats = plugin.get_search_stats(temp_db_path)
|
||||
assert 'fts_enabled' in stats
|
||||
|
||||
def test_search_files_only(self, temp_db_path):
|
||||
"""Test searching only in files."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
plugin.rebuild_index(temp_db_path)
|
||||
|
||||
results = plugin.search(temp_db_path, "API", content_type="files", limit=10)
|
||||
|
||||
# Should find files containing "API"
|
||||
assert isinstance(results, list)
|
||||
for result in results:
|
||||
assert result['type'] == 'file'
|
||||
assert 'file' in result
|
||||
assert 'score' in result
|
||||
|
||||
def test_search_schemas_only(self, temp_db_path):
|
||||
"""Test searching only in schemas."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
plugin.rebuild_index(temp_db_path)
|
||||
|
||||
results = plugin.search(temp_db_path, "schema", content_type="schemas", limit=10)
|
||||
|
||||
# Should find schemas
|
||||
assert isinstance(results, list)
|
||||
for result in results:
|
||||
assert result['type'] == 'schema'
|
||||
assert 'schema' in result
|
||||
assert 'score' in result
|
||||
|
||||
def test_search_all_content(self, temp_db_path):
|
||||
"""Test searching all content types."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
plugin.rebuild_index(temp_db_path)
|
||||
|
||||
results = plugin.search(temp_db_path, "guide", content_type="all", limit=10)
|
||||
|
||||
# Should find both files and schemas (or empty list if FTS5 unavailable)
|
||||
assert isinstance(results, list)
|
||||
|
||||
# If results found, should be properly formatted and sorted
|
||||
if results:
|
||||
# Results should be sorted by score
|
||||
scores = [result.get('score', 0) for result in results]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
# Check result structure
|
||||
for result in results:
|
||||
assert 'type' in result
|
||||
assert 'score' in result
|
||||
|
||||
def test_search_with_pagination(self, temp_db_path):
|
||||
"""Test search with pagination."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
plugin.rebuild_index(temp_db_path)
|
||||
|
||||
# Get first page
|
||||
results1 = plugin.search(temp_db_path, "guide", limit=1, offset=0)
|
||||
|
||||
# Get second page
|
||||
results2 = plugin.search(temp_db_path, "guide", limit=1, offset=1)
|
||||
|
||||
# Results should be different (if there are enough results)
|
||||
if len(results1) > 0 and len(results2) > 0:
|
||||
assert results1[0] != results2[0]
|
||||
|
||||
def test_fallback_search(self, temp_db_path):
|
||||
"""Test fallback search when FTS5 fails."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
|
||||
# Force fallback by using invalid FTS5 query syntax with mock
|
||||
with patch.object(plugin, '_search_files', side_effect=Exception("FTS5 error")):
|
||||
with patch.object(plugin, '_search_schemas', side_effect=Exception("FTS5 error")):
|
||||
results = plugin.search(temp_db_path, "API", content_type="all", limit=10)
|
||||
|
||||
# Should still return results via fallback
|
||||
assert isinstance(results, list)
|
||||
|
||||
def test_get_search_stats(self, temp_db_path):
|
||||
"""Test getting search statistics."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(temp_db_path)
|
||||
|
||||
stats = plugin.get_search_stats(temp_db_path)
|
||||
|
||||
assert 'fts_enabled' in stats
|
||||
assert 'fts_tables' in stats
|
||||
|
||||
|
||||
class TestSearchCLI:
|
||||
"""Test search CLI commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(self):
|
||||
"""Create a temporary database with test data."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add test data
|
||||
db_manager.store_markdown_file("test.md", "# Test\n\nThis is a test document.")
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_search_init_command(self, temp_db_path):
|
||||
"""Test the search init CLI command."""
|
||||
from click.testing import CliRunner
|
||||
from markitect.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('markitect.cli.get_database_path', return_value=temp_db_path):
|
||||
result = runner.invoke(cli, ['search', 'init'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Search indexes initialized" in result.output or "Search plugin not available" in result.output
|
||||
|
||||
def test_search_query_command(self, temp_db_path):
|
||||
"""Test the search query CLI command."""
|
||||
from click.testing import CliRunner
|
||||
from markitect.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('markitect.cli.get_database_path', return_value=temp_db_path):
|
||||
# Initialize search first
|
||||
runner.invoke(cli, ['search', 'init'])
|
||||
|
||||
# Perform search
|
||||
result = runner.invoke(cli, ['search', 'query', 'test'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Should either show results or indicate no search plugin
|
||||
assert "results" in result.output or "Search plugin not available" in result.output
|
||||
|
||||
def test_search_status_command(self, temp_db_path):
|
||||
"""Test the search status CLI command."""
|
||||
from click.testing import CliRunner
|
||||
from markitect.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('markitect.cli.get_database_path', return_value=temp_db_path):
|
||||
result = runner.invoke(cli, ['search', 'status'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Search Index Status" in result.output or "Search plugin not available" in result.output
|
||||
|
||||
def test_search_rebuild_command(self, temp_db_path):
|
||||
"""Test the search rebuild CLI command."""
|
||||
from click.testing import CliRunner
|
||||
from markitect.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('markitect.cli.get_database_path', return_value=temp_db_path):
|
||||
# Initialize search first
|
||||
runner.invoke(cli, ['search', 'init'])
|
||||
|
||||
# Rebuild indexes
|
||||
result = runner.invoke(cli, ['search', 'rebuild'])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"Command output: {result.output}")
|
||||
print(f"Exception: {result.exception}")
|
||||
|
||||
# Should succeed or fail gracefully with plugin unavailable message or database error
|
||||
acceptable_errors = [
|
||||
"Search plugin not available",
|
||||
"database disk image is malformed", # Can happen with concurrent access
|
||||
"database is locked"
|
||||
]
|
||||
|
||||
if result.exit_code == 0:
|
||||
assert "Rebuilding search indexes" in result.output
|
||||
else:
|
||||
# Check if it's an acceptable error
|
||||
assert any(error in result.output for error in acceptable_errors)
|
||||
|
||||
|
||||
class TestSearchIntegration:
|
||||
"""Integration tests for search functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def populated_db_path(self):
|
||||
"""Create a database with realistic test data."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add realistic markdown files
|
||||
files = [
|
||||
("api-documentation.md", """# API Documentation
|
||||
|
||||
## Authentication
|
||||
The API uses Bearer token authentication. Include your token in the Authorization header.
|
||||
|
||||
## Endpoints
|
||||
- GET /users - List all users
|
||||
- POST /users - Create a new user
|
||||
- GET /users/{id} - Get specific user
|
||||
|
||||
## Error Handling
|
||||
All errors return JSON with error message and status code.
|
||||
"""),
|
||||
("graphql-guide.md", """---
|
||||
title: GraphQL Complete Guide
|
||||
tags: [graphql, api, tutorial]
|
||||
author: Development Team
|
||||
---
|
||||
|
||||
# GraphQL Complete Guide
|
||||
|
||||
GraphQL is a query language for APIs and a runtime for executing those queries.
|
||||
|
||||
## Benefits
|
||||
- Single endpoint
|
||||
- Type safety
|
||||
- Efficient data fetching
|
||||
- Strong introspection
|
||||
|
||||
## Schema Definition
|
||||
Define your GraphQL schema using SDL (Schema Definition Language).
|
||||
"""),
|
||||
("project-readme.md", """# MarkiTect Project
|
||||
|
||||
MarkiTect is a comprehensive markdown content management and analysis system.
|
||||
|
||||
## Features
|
||||
- Document indexing and storage
|
||||
- Full text search capabilities
|
||||
- GraphQL API interface
|
||||
- Plugin system for extensibility
|
||||
|
||||
## Installation
|
||||
1. Clone the repository
|
||||
2. Install dependencies: pip install -r requirements.txt
|
||||
3. Initialize database: markitect init
|
||||
|
||||
## Usage Examples
|
||||
Search for content: markitect search query "API documentation"
|
||||
""")
|
||||
]
|
||||
|
||||
for filename, content in files:
|
||||
db_manager.store_markdown_file(filename, content)
|
||||
|
||||
# Add realistic schemas
|
||||
schemas = [
|
||||
("user-schema.json", {
|
||||
"type": "object",
|
||||
"title": "User Schema",
|
||||
"description": "Schema for user profile data in the API",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"},
|
||||
"created_at": {"type": "string", "format": "date-time"}
|
||||
},
|
||||
"required": ["id", "name", "email"]
|
||||
}),
|
||||
("api-response-schema.json", {
|
||||
"type": "object",
|
||||
"title": "API Response Schema",
|
||||
"description": "Standard API response format for all endpoints",
|
||||
"properties": {
|
||||
"data": {"type": "object"},
|
||||
"success": {"type": "boolean"},
|
||||
"message": {"type": "string"},
|
||||
"errors": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["success"]
|
||||
})
|
||||
]
|
||||
|
||||
for filename, schema in schemas:
|
||||
db_manager.store_schema_file(filename, json.dumps(schema))
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
def test_end_to_end_search_workflow(self, populated_db_path):
|
||||
"""Test complete search workflow from initialization to querying."""
|
||||
plugin = FTSSearchPlugin()
|
||||
|
||||
# Initialize search
|
||||
plugin.initialize(populated_db_path)
|
||||
|
||||
# Rebuild indexes
|
||||
stats = plugin.rebuild_index(populated_db_path)
|
||||
|
||||
if plugin.indexer.check_fts_availability(populated_db_path):
|
||||
# If FTS5 is available, should index files
|
||||
assert stats['files_indexed'] >= 0
|
||||
assert stats['schemas_indexed'] >= 0
|
||||
else:
|
||||
# If FTS5 not available, might be 0
|
||||
pass
|
||||
|
||||
# Search for API-related content
|
||||
results = plugin.search(populated_db_path, "API", content_type="all", limit=10)
|
||||
|
||||
# Results should be a list (may be empty if FTS5 not available)
|
||||
assert isinstance(results, list)
|
||||
|
||||
# If we have results, verify they're properly formatted
|
||||
if results:
|
||||
# Should find both files and schemas
|
||||
result_types = {result['type'] for result in results}
|
||||
assert len(result_types) > 0 # At least one type found
|
||||
|
||||
# Verify results have required fields
|
||||
for result in results:
|
||||
assert 'type' in result
|
||||
assert 'score' in result
|
||||
assert result['score'] > 0
|
||||
|
||||
if result['type'] == 'file':
|
||||
assert 'file' in result
|
||||
assert 'filename' in result['file']
|
||||
elif result['type'] == 'schema':
|
||||
assert 'schema' in result
|
||||
assert 'filename' in result['schema']
|
||||
|
||||
def test_search_ranking_quality(self, populated_db_path):
|
||||
"""Test that search ranking produces sensible results."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(populated_db_path)
|
||||
plugin.rebuild_index(populated_db_path)
|
||||
|
||||
# Search for "GraphQL"
|
||||
results = plugin.search(populated_db_path, "GraphQL", content_type="files", limit=10)
|
||||
|
||||
if results:
|
||||
# The GraphQL guide should rank highest
|
||||
top_result = results[0]
|
||||
assert 'graphql' in top_result['file']['filename'].lower()
|
||||
|
||||
# Search for exact phrase
|
||||
results = plugin.search(populated_db_path, '"API documentation"', content_type="files", limit=10)
|
||||
|
||||
if results:
|
||||
# Should find exact phrase matches
|
||||
for result in results:
|
||||
content = result['file'].get('content', '').lower()
|
||||
# Either in content or highlighted
|
||||
assert 'api documentation' in content or 'api documentation' in result.get('highlight', '').lower()
|
||||
|
||||
def test_search_error_handling(self, populated_db_path):
|
||||
"""Test search error handling and edge cases."""
|
||||
plugin = FTSSearchPlugin()
|
||||
plugin.initialize(populated_db_path)
|
||||
|
||||
# Empty query
|
||||
results = plugin.search(populated_db_path, "", content_type="all", limit=10)
|
||||
assert isinstance(results, list)
|
||||
|
||||
# Very long query
|
||||
long_query = "word " * 100
|
||||
results = plugin.search(populated_db_path, long_query, content_type="all", limit=10)
|
||||
assert isinstance(results, list)
|
||||
|
||||
# Special characters
|
||||
results = plugin.search(populated_db_path, "query with @#$%", content_type="all", limit=10)
|
||||
assert isinstance(results, list)
|
||||
|
||||
# Zero limit
|
||||
results = plugin.search(populated_db_path, "API", content_type="all", limit=0)
|
||||
assert len(results) == 0
|
||||
@@ -1,619 +0,0 @@
|
||||
"""
|
||||
Comprehensive tests for GraphQL interface (Issue #9).
|
||||
|
||||
Tests all aspects of the GraphQL read interface including:
|
||||
- Schema definition and validation
|
||||
- Resolver functionality
|
||||
- Server endpoints
|
||||
- CLI integration
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from markitect.graphql.schema import schema, MarkdownFile, Schema as SchemaType, AST, DatabaseStats
|
||||
from markitect.graphql.resolvers import Query, MarkiTectResolver, get_default_database_path
|
||||
from markitect.graphql.server import GraphQLServer, GraphQLClient
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add sample data
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Sample markdown file
|
||||
cursor.execute("""
|
||||
INSERT INTO markdown_files (filename, content, front_matter, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
'test.md',
|
||||
'# Test Document\n\nThis is a test document with [a link](http://example.com).',
|
||||
'{"title": "Test Document", "author": "Test Author"}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
# Sample schema
|
||||
cursor.execute("""
|
||||
INSERT INTO schemas (filename, title, description, schema_content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
'test-schema.json',
|
||||
'Test Schema',
|
||||
'A test schema for testing',
|
||||
'{"type": "object", "properties": {"name": {"type": "string"}}}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphql_resolver(temp_db_path):
|
||||
"""Create GraphQL resolver with test database."""
|
||||
return MarkiTectResolver(temp_db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphql_query(temp_db_path):
|
||||
"""Create GraphQL Query instance with test database."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
return Query()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app(temp_db_path):
|
||||
"""Create Flask app for testing GraphQL server."""
|
||||
server = GraphQLServer(db_path=temp_db_path, enable_cors=True)
|
||||
app = server.create_app()
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
|
||||
class TestGraphQLSchema:
|
||||
"""Test GraphQL schema definition and validation."""
|
||||
|
||||
def test_schema_is_valid(self):
|
||||
"""Test that the GraphQL schema is valid."""
|
||||
assert schema is not None
|
||||
assert hasattr(schema, 'execute')
|
||||
|
||||
def test_schema_has_required_types(self):
|
||||
"""Test that schema contains all required types."""
|
||||
schema_str = str(schema)
|
||||
|
||||
# Check for main types
|
||||
assert 'MarkdownFile' in schema_str
|
||||
assert 'Schema' in schema_str
|
||||
assert 'AST' in schema_str
|
||||
assert 'DatabaseStats' in schema_str
|
||||
assert 'SearchResult' in schema_str
|
||||
|
||||
def test_query_type_fields(self):
|
||||
"""Test that Query type has all required fields."""
|
||||
schema_str = str(schema)
|
||||
|
||||
# Check for query fields
|
||||
assert 'markdownFile' in schema_str
|
||||
assert 'markdownFiles' in schema_str
|
||||
assert 'schema' in schema_str
|
||||
assert 'schemas' in schema_str
|
||||
assert 'ast' in schema_str
|
||||
assert 'search' in schema_str
|
||||
assert 'databaseStats' in schema_str
|
||||
assert 'astQuery' in schema_str
|
||||
|
||||
|
||||
class TestGraphQLResolvers:
|
||||
"""Test GraphQL resolver functionality."""
|
||||
|
||||
def test_resolver_initialization(self, temp_db_path):
|
||||
"""Test resolver initializes correctly."""
|
||||
resolver = MarkiTectResolver(temp_db_path)
|
||||
|
||||
assert resolver.db_path == temp_db_path
|
||||
assert resolver.db_manager is not None
|
||||
assert resolver.ast_service is not None
|
||||
|
||||
def test_get_connection(self, graphql_resolver):
|
||||
"""Test database connection method."""
|
||||
conn = graphql_resolver.get_connection()
|
||||
|
||||
assert conn is not None
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
conn.close()
|
||||
|
||||
def test_row_to_dict(self, graphql_resolver):
|
||||
"""Test row to dictionary conversion."""
|
||||
conn = graphql_resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1 as test_col")
|
||||
row = cursor.fetchone()
|
||||
|
||||
result = graphql_resolver.row_to_dict(cursor, row)
|
||||
assert result == {'test_col': 1}
|
||||
conn.close()
|
||||
|
||||
def test_resolve_markdown_file_by_id(self, graphql_query):
|
||||
"""Test resolving markdown file by ID."""
|
||||
result = graphql_query.resolve_markdown_file(None, id=1)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MarkdownFile)
|
||||
assert result.filename == 'test.md'
|
||||
assert 'Test Document' in result.content
|
||||
|
||||
def test_resolve_markdown_file_by_filename(self, graphql_query):
|
||||
"""Test resolving markdown file by filename."""
|
||||
result = graphql_query.resolve_markdown_file(None, filename='test.md')
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MarkdownFile)
|
||||
assert result.id == 1
|
||||
|
||||
def test_resolve_markdown_file_not_found(self, graphql_query):
|
||||
"""Test resolving non-existent markdown file."""
|
||||
result = graphql_query.resolve_markdown_file(None, id=999)
|
||||
assert result is None
|
||||
|
||||
result = graphql_query.resolve_markdown_file(None, filename='nonexistent.md')
|
||||
assert result is None
|
||||
|
||||
def test_resolve_schema_by_id(self, graphql_query):
|
||||
"""Test resolving schema by ID."""
|
||||
result = graphql_query.resolve_schema(None, id=1)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, SchemaType)
|
||||
assert result.title == 'Test Schema'
|
||||
|
||||
def test_resolve_markdown_files_list(self, graphql_query):
|
||||
"""Test resolving list of markdown files."""
|
||||
results = graphql_query.resolve_markdown_files(None, limit=10, offset=0)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(isinstance(f, MarkdownFile) for f in results)
|
||||
|
||||
def test_resolve_schemas_list(self, graphql_query):
|
||||
"""Test resolving list of schemas."""
|
||||
results = graphql_query.resolve_schemas(None, limit=10, offset=0)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(isinstance(s, SchemaType) for s in results)
|
||||
|
||||
def test_resolve_search_files(self, graphql_query):
|
||||
"""Test search functionality for files."""
|
||||
results = graphql_query.resolve_search(None, query="Test", type="file", limit=10)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(hasattr(r, 'type') and hasattr(r, 'score') for r in results)
|
||||
|
||||
def test_resolve_database_stats(self, graphql_query):
|
||||
"""Test database statistics resolver."""
|
||||
result = graphql_query.resolve_database_stats(None)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, DatabaseStats)
|
||||
assert result.total_files >= 1
|
||||
assert result.total_schemas >= 1
|
||||
assert result.total_size_bytes > 0
|
||||
|
||||
@patch('markitect.graphql.resolvers.Path.exists')
|
||||
def test_resolve_ast_file_not_found(self, mock_exists, graphql_query):
|
||||
"""Test AST resolution when file doesn't exist."""
|
||||
mock_exists.return_value = False
|
||||
|
||||
result = graphql_query.resolve_ast(None, filename='nonexistent.md')
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGraphQLServer:
|
||||
"""Test GraphQL server functionality."""
|
||||
|
||||
def test_server_initialization(self, temp_db_path):
|
||||
"""Test server initializes correctly."""
|
||||
server = GraphQLServer(db_path=temp_db_path, enable_cors=True)
|
||||
|
||||
assert server.db_path == temp_db_path
|
||||
assert server.enable_cors is True
|
||||
assert server.app is None
|
||||
|
||||
def test_server_initialization_without_flask(self):
|
||||
"""Test server initialization when Flask is not available."""
|
||||
with patch('markitect.graphql.server.FLASK_AVAILABLE', False):
|
||||
with pytest.raises(ImportError, match="Flask is required"):
|
||||
GraphQLServer()
|
||||
|
||||
def test_create_app(self, temp_db_path):
|
||||
"""Test Flask app creation."""
|
||||
server = GraphQLServer(db_path=temp_db_path)
|
||||
app = server.create_app()
|
||||
|
||||
assert app is not None
|
||||
assert server.app is app
|
||||
|
||||
def test_graphql_endpoint_post(self, flask_app):
|
||||
"""Test GraphQL POST endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
query = '{ databaseStats { totalFiles } }'
|
||||
response = client.post('/graphql',
|
||||
json={'query': query},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'data' in data
|
||||
assert 'databaseStats' in data['data']
|
||||
|
||||
def test_graphql_endpoint_invalid_json(self, flask_app):
|
||||
"""Test GraphQL endpoint with invalid JSON."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.post('/graphql',
|
||||
data='invalid json',
|
||||
content_type='application/json')
|
||||
|
||||
# Flask returns 500 for malformed JSON, which is reasonable
|
||||
assert response.status_code in [400, 500]
|
||||
|
||||
def test_graphql_endpoint_no_query(self, flask_app):
|
||||
"""Test GraphQL endpoint without query."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.post('/graphql',
|
||||
json={},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_graphql_playground(self, flask_app):
|
||||
"""Test GraphQL playground endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/graphql')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'GraphQL Playground' in response.get_data(as_text=True)
|
||||
|
||||
def test_schema_endpoint(self, flask_app):
|
||||
"""Test schema introspection endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/schema')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'schema' in data
|
||||
|
||||
def test_health_check_healthy(self, flask_app):
|
||||
"""Test health check endpoint when healthy."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/health')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'healthy'
|
||||
assert data['database'] == 'connected'
|
||||
|
||||
def test_health_check_unhealthy(self, temp_db_path):
|
||||
"""Test health check when database is unavailable."""
|
||||
# Use non-existent database path
|
||||
server = GraphQLServer(db_path='/nonexistent/path.db')
|
||||
app = server.create_app()
|
||||
|
||||
with app.test_client() as client:
|
||||
response = client.get('/health')
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'unhealthy'
|
||||
|
||||
|
||||
class TestGraphQLClient:
|
||||
"""Test GraphQL client functionality."""
|
||||
|
||||
def test_client_initialization(self):
|
||||
"""Test client initializes correctly."""
|
||||
client = GraphQLClient("http://localhost:5000/graphql")
|
||||
assert client.endpoint == "http://localhost:5000/graphql"
|
||||
|
||||
def test_client_default_endpoint(self):
|
||||
"""Test client uses default endpoint."""
|
||||
client = GraphQLClient()
|
||||
assert client.endpoint == "http://localhost:5000/graphql"
|
||||
|
||||
@patch('requests.post')
|
||||
def test_client_execute_query(self, mock_post):
|
||||
"""Test client query execution."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'data': {'databaseStats': {'totalFiles': 5}}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = GraphQLClient()
|
||||
result = client.execute('{ databaseStats { totalFiles } }')
|
||||
|
||||
assert result['data']['databaseStats']['totalFiles'] == 5
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_client_execute_local(self, temp_db_path):
|
||||
"""Test client local query execution."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
client = GraphQLClient()
|
||||
result = client.execute_local('{ databaseStats { totalFiles } }', context={'db_path': temp_db_path})
|
||||
|
||||
assert result is not None
|
||||
assert 'data' in result
|
||||
# The databaseStats resolver might return None if db is empty, so let's be more flexible
|
||||
if result['data']['databaseStats'] is not None:
|
||||
assert result['data']['databaseStats']['totalFiles'] >= 0
|
||||
|
||||
def test_client_execute_without_requests(self):
|
||||
"""Test client execution when requests is not available."""
|
||||
import builtins
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == 'requests':
|
||||
raise ImportError("No module named 'requests'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
client = GraphQLClient()
|
||||
|
||||
with pytest.raises(ImportError, match="requests is required"):
|
||||
client.execute('{ databaseStats { totalFiles } }')
|
||||
|
||||
|
||||
class TestGraphQLQueries:
|
||||
"""Test actual GraphQL query execution."""
|
||||
|
||||
def test_simple_database_stats_query(self, temp_db_path):
|
||||
"""Test simple database stats query."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
databaseStats {
|
||||
totalFiles
|
||||
totalSchemas
|
||||
totalSizeBytes
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
assert 'databaseStats' in result.data
|
||||
if result.data['databaseStats'] is not None:
|
||||
assert result.data['databaseStats']['totalFiles'] >= 1
|
||||
assert result.data['databaseStats']['totalSchemas'] >= 1
|
||||
|
||||
def test_markdown_file_query_with_computed_fields(self, temp_db_path):
|
||||
"""Test markdown file query with computed fields."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
markdownFile(id: 1) {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
lineCount
|
||||
hasFrontMatter
|
||||
frontMatter {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
data = result.data['markdownFile']
|
||||
if data is not None:
|
||||
assert data['id'] == 1
|
||||
assert data['filename'] == 'test.md'
|
||||
assert data['wordCount'] > 0
|
||||
assert data['lineCount'] > 0
|
||||
assert data['hasFrontMatter'] is True
|
||||
assert len(data['frontMatter']) > 0
|
||||
|
||||
def test_search_query(self, temp_db_path):
|
||||
"""Test search functionality."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
search(query: "Test", type: "all", limit: 10) {
|
||||
type
|
||||
score
|
||||
file {
|
||||
filename
|
||||
}
|
||||
schema {
|
||||
title
|
||||
}
|
||||
highlight
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
if result.data['search'] is not None:
|
||||
assert len(result.data['search']) >= 0
|
||||
|
||||
def test_pagination_query(self, temp_db_path):
|
||||
"""Test pagination in list queries."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
markdownFiles(limit: 1, offset: 0) {
|
||||
id
|
||||
filename
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
if result.data['markdownFiles'] is not None:
|
||||
assert len(result.data['markdownFiles']) <= 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestGraphQLCLIIntegration:
|
||||
"""Test GraphQL CLI command integration."""
|
||||
|
||||
def test_graphql_schema_command(self, isolated_environment):
|
||||
"""Test graphql-schema CLI command."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-schema", "--local"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "type Query" in result.stdout
|
||||
|
||||
def test_graphql_query_command(self, isolated_environment):
|
||||
"""Test graphql-query CLI command."""
|
||||
query = "{ databaseStats { totalFiles } }"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-query", query, "--local"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
# The database might be empty in test environment, so check for JSON structure
|
||||
assert "databaseStats" in result.stdout
|
||||
|
||||
def test_graphql_examples_command(self, isolated_environment):
|
||||
"""Test graphql-examples CLI command."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-examples"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "GraphQL Query Examples" in result.stdout
|
||||
assert "databaseStats" in result.stdout
|
||||
|
||||
@patch('markitect.graphql.server.GraphQLServer')
|
||||
def test_graphql_serve_command(self, mock_server_class, isolated_environment):
|
||||
"""Test graphql-serve CLI command."""
|
||||
mock_server = Mock()
|
||||
mock_server_class.return_value = mock_server
|
||||
|
||||
# We can't actually start the server in tests, so we just test command parsing
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-serve", "--help"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Start GraphQL server" in result.stdout
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling in GraphQL interface."""
|
||||
|
||||
def test_invalid_query_syntax(self, temp_db_path):
|
||||
"""Test handling of invalid GraphQL syntax."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ invalidSyntax }"
|
||||
|
||||
result = schema.execute(query)
|
||||
|
||||
assert result.errors is not None
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_nonexistent_field_query(self, temp_db_path):
|
||||
"""Test querying nonexistent fields."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ nonexistentField }"
|
||||
|
||||
result = schema.execute(query)
|
||||
|
||||
assert result.errors is not None
|
||||
|
||||
def test_resolver_database_error(self, temp_db_path):
|
||||
"""Test resolver behavior when database is corrupted."""
|
||||
# Corrupt the database file
|
||||
with open(temp_db_path, 'w') as f:
|
||||
f.write("corrupted data")
|
||||
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ databaseStats { totalFiles } }"
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
# Should handle database errors gracefully - either with errors or None data
|
||||
assert result.errors is not None or result.data['databaseStats'] is None
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test utility functions in GraphQL module."""
|
||||
|
||||
def test_get_default_database_path_with_env(self):
|
||||
"""Test get_default_database_path with environment variable."""
|
||||
with patch.dict(os.environ, {'MARKITECT_DB': '/custom/path.db'}):
|
||||
path = get_default_database_path()
|
||||
assert path == '/custom/path.db'
|
||||
|
||||
def test_get_default_database_path_default(self):
|
||||
"""Test get_default_database_path with default location."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
path = get_default_database_path()
|
||||
assert path.endswith('markitect.db')
|
||||
assert '.markitect' in path
|
||||
@@ -1,295 +0,0 @@
|
||||
"""
|
||||
Integration tests for complete MarkdownMatters CLI implementation.
|
||||
Tests all four command families working together.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect_content.commands import content_get, content_stats
|
||||
from markitect.matter_frontmatter.commands import frontmatter_get, frontmatter_keys
|
||||
from markitect.matter_contentmatter.commands import contentmatter_get, contentmatter_keys
|
||||
from markitect.matter_tailmatter.commands import tailmatter_get, tailmatter_check
|
||||
|
||||
|
||||
class TestMarkdownMattersIntegration:
|
||||
"""Test complete MarkdownMatters functionality integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def complete_document(self):
|
||||
"""A complete MarkdownMatters document with all three zones."""
|
||||
return """---
|
||||
title: "Complete MarkdownMatters Document"
|
||||
author: "Integration Test"
|
||||
version: 1.0
|
||||
status: "testing"
|
||||
---
|
||||
|
||||
# Complete MarkdownMatters Document
|
||||
|
||||
This document demonstrates all three matter zones working together.
|
||||
|
||||
Author: Dr. Test Researcher
|
||||
Institution: MarkdownMatters University
|
||||
Email: test@markdownmatters.edu
|
||||
Project: Integration Testing
|
||||
Version: 2.0
|
||||
Status: Active
|
||||
|
||||
## Research Content
|
||||
|
||||
Research Method: Integration Testing
|
||||
Sample Size: Complete document
|
||||
Test Framework: MarkdownMatters CLI
|
||||
|
||||
The content includes various MultiMarkdown key-value pairs that provide contextual metadata.
|
||||
|
||||
## Results
|
||||
|
||||
Result Status: All systems operational
|
||||
Performance: Excellent
|
||||
Coverage: 100%
|
||||
|
||||
All matter zones are properly separated and accessible through their respective CLI commands.
|
||||
|
||||
---
|
||||
|
||||
```yaml tailmatter
|
||||
qa_checklist:
|
||||
- requirement: "All three matter zones tested"
|
||||
complete: true
|
||||
- requirement: "CLI commands validated"
|
||||
complete: true
|
||||
- requirement: "Integration verified"
|
||||
complete: false
|
||||
|
||||
editorial:
|
||||
status: "Integration Testing"
|
||||
reviewer: "integration.tester@markdownmatters.edu"
|
||||
version: 3.0
|
||||
|
||||
agent_config:
|
||||
role: "integration_validator"
|
||||
access_scope: "all_zones"
|
||||
validation_mode: "comprehensive"
|
||||
```"""
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""CLI test runner."""
|
||||
return CliRunner()
|
||||
|
||||
def test_all_command_families_work_on_same_document(self, runner, complete_document):
|
||||
"""Test that all four command families can process the same document."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(complete_document)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Test content commands
|
||||
result = runner.invoke(content_get, ['--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "Complete MarkdownMatters Document" in result.output
|
||||
assert "---" not in result.output # No frontmatter
|
||||
assert "qa_checklist" not in result.output # No tailmatter
|
||||
|
||||
result = runner.invoke(content_stats, ['--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "word_count" in result.output
|
||||
|
||||
# Test frontmatter commands
|
||||
result = runner.invoke(frontmatter_get, ['title', '--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "Complete MarkdownMatters Document" in result.output
|
||||
|
||||
result = runner.invoke(frontmatter_keys, ['--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "title" in result.output
|
||||
assert "author" in result.output
|
||||
|
||||
# Test contentmatter commands
|
||||
result = runner.invoke(contentmatter_get, ['Author', '--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "Dr. Test Researcher" in result.output
|
||||
|
||||
result = runner.invoke(contentmatter_keys, ['--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "Author" in result.output
|
||||
assert "Institution" in result.output
|
||||
|
||||
# Test tailmatter commands
|
||||
result = runner.invoke(tailmatter_get, ['editorial.status', '--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "Integration Testing" in result.output
|
||||
|
||||
result = runner.invoke(tailmatter_check, ['--file', temp_file])
|
||||
assert result.exit_code == 0
|
||||
assert "QA Checklist Status" in result.output
|
||||
assert "✅" in result.output
|
||||
assert "❌" in result.output
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_matter_zone_separation(self, runner, complete_document):
|
||||
"""Test that each command family only accesses its designated zone."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(complete_document)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Frontmatter should not include contentmatter or tailmatter
|
||||
result = runner.invoke(frontmatter_keys, ['--file', temp_file])
|
||||
assert "Author" not in result.output # This is contentmatter
|
||||
assert "qa_checklist" not in result.output # This is tailmatter
|
||||
|
||||
# Contentmatter should not include frontmatter or tailmatter
|
||||
result = runner.invoke(contentmatter_keys, ['--file', temp_file])
|
||||
assert "title" not in result.output # This is frontmatter
|
||||
assert "qa_checklist" not in result.output # This is tailmatter
|
||||
|
||||
# Content should not include any matter zones in the actual content
|
||||
result = runner.invoke(content_get, ['--file', temp_file])
|
||||
assert "title:" not in result.output # No frontmatter YAML
|
||||
assert "qa_checklist:" not in result.output # No tailmatter YAML
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_performance_with_large_document(self, runner):
|
||||
"""Test performance with a large document containing all matter zones."""
|
||||
# Create a large document
|
||||
large_content = []
|
||||
large_content.append("---")
|
||||
large_content.append("title: 'Large Document Performance Test'")
|
||||
for i in range(50):
|
||||
large_content.append(f"field_{i}: 'value_{i}'")
|
||||
large_content.append("---")
|
||||
large_content.append("")
|
||||
|
||||
large_content.append("# Large Document Performance Test")
|
||||
large_content.append("")
|
||||
|
||||
# Add many contentmatter pairs
|
||||
for i in range(100):
|
||||
large_content.append(f"Data Field {i}: Value for field {i}")
|
||||
large_content.append("")
|
||||
|
||||
# Add substantial content
|
||||
for i in range(50):
|
||||
large_content.append(f"## Section {i}")
|
||||
large_content.append("")
|
||||
large_content.append(f"Content for section {i} with detailed information and multiple paragraphs.")
|
||||
large_content.append("")
|
||||
large_content.append("More content here to make the document substantial in size.")
|
||||
large_content.append("")
|
||||
|
||||
large_content.append("---")
|
||||
large_content.append("")
|
||||
large_content.append("```yaml tailmatter")
|
||||
large_content.append("qa_checklist:")
|
||||
for i in range(20):
|
||||
complete = "true" if i % 3 == 0 else "false"
|
||||
large_content.append(f" - requirement: 'Test requirement {i}'")
|
||||
large_content.append(f" complete: {complete}")
|
||||
large_content.append("editorial:")
|
||||
large_content.append(" status: 'Performance Testing'")
|
||||
large_content.append("```")
|
||||
|
||||
large_document = "\n".join(large_content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(large_document)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Test that all commands complete in reasonable time
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
result = runner.invoke(content_stats, ['--file', temp_file])
|
||||
content_time = time.time() - start_time
|
||||
assert result.exit_code == 0
|
||||
assert content_time < 2.0 # Should complete in under 2 seconds
|
||||
|
||||
start_time = time.time()
|
||||
result = runner.invoke(frontmatter_keys, ['--file', temp_file])
|
||||
frontmatter_time = time.time() - start_time
|
||||
assert result.exit_code == 0
|
||||
assert frontmatter_time < 1.0 # Should complete in under 1 second
|
||||
|
||||
start_time = time.time()
|
||||
result = runner.invoke(contentmatter_keys, ['--file', temp_file])
|
||||
contentmatter_time = time.time() - start_time
|
||||
assert result.exit_code == 0
|
||||
assert contentmatter_time < 2.0 # Should complete in under 2 seconds
|
||||
|
||||
start_time = time.time()
|
||||
result = runner.invoke(tailmatter_check, ['--file', temp_file])
|
||||
tailmatter_time = time.time() - start_time
|
||||
assert result.exit_code == 0
|
||||
assert tailmatter_time < 1.0 # Should complete in under 1 second
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_error_handling_consistency(self, runner):
|
||||
"""Test that all command families handle errors consistently."""
|
||||
non_existent_file = "/tmp/non_existent_file.md"
|
||||
|
||||
# All commands should handle missing files gracefully
|
||||
commands_and_args = [
|
||||
(content_get, ['--file', non_existent_file]),
|
||||
(content_stats, ['--file', non_existent_file]),
|
||||
(frontmatter_get, ['title', '--file', non_existent_file]),
|
||||
(frontmatter_keys, ['--file', non_existent_file]),
|
||||
(contentmatter_get, ['Author', '--file', non_existent_file]),
|
||||
(contentmatter_keys, ['--file', non_existent_file]),
|
||||
(tailmatter_get, ['editorial.status', '--file', non_existent_file]),
|
||||
(tailmatter_check, ['--file', non_existent_file]),
|
||||
]
|
||||
|
||||
for command, args in commands_and_args:
|
||||
result = runner.invoke(command, args)
|
||||
assert result.exit_code != 0 # Should fail for non-existent file
|
||||
|
||||
def test_help_commands_consistency(self, runner):
|
||||
"""Test that all commands provide consistent help."""
|
||||
commands = [
|
||||
content_get, content_stats,
|
||||
frontmatter_get, frontmatter_keys,
|
||||
contentmatter_get, contentmatter_keys,
|
||||
tailmatter_get, tailmatter_check
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
result = runner.invoke(command, ['--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Usage:" in result.output
|
||||
assert "--help" in result.output
|
||||
|
||||
def test_output_format_consistency(self, runner, complete_document):
|
||||
"""Test that commands with format options work consistently."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(complete_document)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Test JSON format consistency
|
||||
result = runner.invoke(content_stats, ['--file', temp_file, '--format', 'json'])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith('{')
|
||||
|
||||
result = runner.invoke(frontmatter_keys, ['--file', temp_file, '--format', 'json'])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith('[')
|
||||
|
||||
result = runner.invoke(contentmatter_keys, ['--file', temp_file, '--format', 'json'])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith('[')
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
@@ -1,454 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect period management CLI commands.
|
||||
|
||||
This module tests the command-line interface for period management including:
|
||||
- Period creation, listing, and status management
|
||||
- Period calculation and lifecycle operations
|
||||
- CLI error handling and validation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.finance.cli import cost_commands
|
||||
from markitect.finance.models import FinanceModels
|
||||
from markitect.finance.period_manager import PeriodManager
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem
|
||||
|
||||
|
||||
class TestPeriodCLICommands:
|
||||
"""Test suite for period management CLI commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def setup_test_data(self, temp_db):
|
||||
"""Setup test database with sample period data."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
period_manager = PeriodManager(temp_db)
|
||||
|
||||
# Create sample period
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
period_type='monthly'
|
||||
)
|
||||
|
||||
return temp_db, period_id
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""Create Click test runner."""
|
||||
return CliRunner()
|
||||
|
||||
def test_period_create_success(self, runner, temp_db):
|
||||
"""Test period creation via CLI."""
|
||||
# Initialize database first
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-02-01',
|
||||
'--end-date', '2025-02-28',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "📅 Period: 2025-02-01 to 2025-02-28" in result.output
|
||||
assert "📊 Type: monthly" in result.output
|
||||
|
||||
def test_period_create_with_loss_forward(self, runner, temp_db):
|
||||
"""Test period creation with loss carried forward."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-03-01',
|
||||
'--end-date', '2025-03-31',
|
||||
'--loss-forward', '15.75',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "💸 Loss carried forward: €15.7500" in result.output
|
||||
|
||||
def test_period_create_invalid_dates(self, runner, temp_db):
|
||||
"""Test period creation with invalid date format."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', 'invalid-date',
|
||||
'--end-date', '2025-02-28',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Dates must be in YYYY-MM-DD format" in result.output
|
||||
|
||||
def test_period_create_overlapping_fails(self, runner, setup_test_data):
|
||||
"""Test that creating overlapping periods fails."""
|
||||
temp_db, existing_period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-01-15', # Overlaps with existing period
|
||||
'--end-date', '2025-02-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
assert "overlaps" in result.output.lower()
|
||||
|
||||
def test_period_list_all(self, runner, setup_test_data):
|
||||
"""Test listing all periods."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "📅 Calculation Periods" in result.output
|
||||
assert "2025-01-01" in result.output
|
||||
assert "2025-01-31" in result.output
|
||||
assert "Total: 1 periods" in result.output
|
||||
|
||||
def test_period_list_with_status_filter(self, runner, setup_test_data):
|
||||
"""Test listing periods with status filter."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
# Create second period and close it
|
||||
period_manager = PeriodManager(temp_db)
|
||||
period_id2 = period_manager.create_period(
|
||||
period_start=date(2025, 2, 1),
|
||||
period_end=date(2025, 2, 28)
|
||||
)
|
||||
period_manager.close_period(period_id2)
|
||||
|
||||
# Filter by open status
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--status', 'open',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-01-01" in result.output # First period should be shown
|
||||
assert "2025-02-01" not in result.output # Second period should be filtered out
|
||||
|
||||
# Filter by closed status
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--status', 'closed',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-02-01" in result.output # Second period should be shown
|
||||
assert "2025-01-01" not in result.output # First period should be filtered out
|
||||
|
||||
def test_period_list_with_date_filters(self, runner, temp_db):
|
||||
"""Test listing periods with date range filters."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
period_manager = PeriodManager(temp_db)
|
||||
|
||||
# Create periods in different months
|
||||
jan_period = period_manager.create_period(date(2025, 1, 1), date(2025, 1, 31))
|
||||
feb_period = period_manager.create_period(date(2025, 2, 1), date(2025, 2, 28))
|
||||
|
||||
# Filter by start date
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--start-from', '2025-02-01',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-02-01" in result.output
|
||||
assert "2025-01-01" not in result.output
|
||||
|
||||
def test_period_list_empty(self, runner, temp_db):
|
||||
"""Test listing periods when none exist."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No periods found matching criteria" in result.output
|
||||
|
||||
def test_period_show_details(self, runner, setup_test_data):
|
||||
"""Test showing period details."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"📅 Period #{period_id} Details" in result.output
|
||||
assert "Start Date: 2025-01-01" in result.output
|
||||
assert "End Date: 2025-01-31" in result.output
|
||||
assert "Type: monthly" in result.output
|
||||
assert "Status: open" in result.output
|
||||
|
||||
def test_period_show_nonexistent(self, runner, temp_db):
|
||||
"""Test showing non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Period #999 not found" in result.output
|
||||
|
||||
def test_period_calculate(self, runner, setup_test_data):
|
||||
"""Test period cost calculation."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
# Add some cost items for calculation
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
|
||||
cost_item = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Test Server',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('25.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'calculate', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"📊 Period #{period_id} Cost Calculation" in result.output
|
||||
assert "Period: 2025-01-01 to 2025-01-31" in result.output
|
||||
assert "Monthly Recurring: €25.00" in result.output
|
||||
assert "Total Period Cost: €25.00" in result.output
|
||||
|
||||
def test_period_calculate_nonexistent(self, runner, temp_db):
|
||||
"""Test calculating costs for non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'calculate', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error calculating period:" in result.output
|
||||
|
||||
def test_period_status_update(self, runner, setup_test_data):
|
||||
"""Test period status update."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', str(period_id), 'calculating',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"✅ Period #{period_id} status updated to 'calculating'" in result.output
|
||||
|
||||
# Verify the status was actually updated
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert "Status: calculating" in result.output
|
||||
|
||||
def test_period_status_update_invalid_status(self, runner, setup_test_data):
|
||||
"""Test period status update with invalid status."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', str(period_id), 'invalid',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 2 # Click validation error
|
||||
assert "Invalid value" in result.output
|
||||
|
||||
def test_period_status_update_nonexistent(self, runner, temp_db):
|
||||
"""Test status update for non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', '999', 'calculating',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_period_close(self, runner, setup_test_data):
|
||||
"""Test period closure."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'close', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"✅ Period #{period_id} has been closed" in result.output
|
||||
assert "💰 Final total cost:" in result.output
|
||||
|
||||
# Verify the period is actually closed
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert "Status: closed" in result.output
|
||||
|
||||
def test_period_close_nonexistent(self, runner, temp_db):
|
||||
"""Test closing non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'close', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_period_current_exists(self, runner, setup_test_data):
|
||||
"""Test finding current period when it exists."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', '2025-01-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "📅 Current Active Period" in result.output
|
||||
assert f"Period #{period_id}" in result.output
|
||||
assert "Dates: 2025-01-01 to 2025-01-31" in result.output
|
||||
|
||||
def test_period_current_not_found(self, runner, temp_db):
|
||||
"""Test finding current period when none exists."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', '2025-03-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No active period found for 2025-03-15" in result.output
|
||||
|
||||
def test_period_current_default_to_today(self, runner, temp_db):
|
||||
"""Test current period defaults to today."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No active period found for today" in result.output
|
||||
assert "💡 Create one with:" in result.output
|
||||
assert "markitect cost period create" in result.output
|
||||
|
||||
def test_period_current_invalid_date(self, runner, temp_db):
|
||||
"""Test current period with invalid date format."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', 'invalid-date',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Date must be in YYYY-MM-DD format" in result.output
|
||||
|
||||
def test_period_help_commands(self, runner):
|
||||
"""Test help output for period commands."""
|
||||
# Test main period help
|
||||
result = runner.invoke(cost_commands, ['period', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Manage calculation periods and lifecycle" in result.output
|
||||
|
||||
# Test create help
|
||||
result = runner.invoke(cost_commands, ['period', 'create', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Create a new calculation period" in result.output
|
||||
|
||||
# Test list help
|
||||
result = runner.invoke(cost_commands, ['period', 'list', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "List calculation periods with optional filtering" in result.output
|
||||
|
||||
def test_period_commands_missing_database(self, runner):
|
||||
"""Test period commands without database specification."""
|
||||
# These should use default config path and still work or show appropriate error
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list'
|
||||
])
|
||||
|
||||
# Should succeed with default database configuration
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_period_create_quarterly_type(self, runner, temp_db):
|
||||
"""Test creating quarterly period type."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-04-01',
|
||||
'--end-date', '2025-06-30',
|
||||
'--type', 'quarterly',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "📊 Type: quarterly" in result.output
|
||||
@@ -1,489 +0,0 @@
|
||||
"""
|
||||
Tests for MarkiTect Period Management Framework.
|
||||
|
||||
This module tests the complete period lifecycle management system including:
|
||||
- Period creation, status management, and lifecycle transitions
|
||||
- Period overlap validation and conflict resolution
|
||||
- Period calculations and cost aggregation
|
||||
- Period closure validation and audit trails
|
||||
- Current period detection and auto-creation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from markitect.finance.period_manager import PeriodManager, PeriodStatus, Period
|
||||
from markitect.finance.models import FinanceModels
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem
|
||||
|
||||
|
||||
class TestPeriodManager:
|
||||
"""Test suite for period management system."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(self):
|
||||
"""Create temporary database for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
@pytest.fixture
|
||||
def period_manager(self, temp_db):
|
||||
"""Create period manager with initialized database."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
return PeriodManager(temp_db)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_period_data(self):
|
||||
"""Sample period data for testing."""
|
||||
return {
|
||||
'period_start': date(2025, 1, 1),
|
||||
'period_end': date(2025, 1, 31),
|
||||
'period_type': 'monthly'
|
||||
}
|
||||
|
||||
def test_period_status_enum(self):
|
||||
"""Test period status enumeration."""
|
||||
assert PeriodStatus.OPEN.value == 'open'
|
||||
assert PeriodStatus.CALCULATING.value == 'calculating'
|
||||
assert PeriodStatus.CLOSED.value == 'closed'
|
||||
|
||||
def test_period_dataclass(self):
|
||||
"""Test Period dataclass creation."""
|
||||
period = Period(
|
||||
id=1,
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
period_type='monthly',
|
||||
status='open',
|
||||
total_costs=Decimal('100.50')
|
||||
)
|
||||
|
||||
assert period.id == 1
|
||||
assert period.period_start == date(2025, 1, 1)
|
||||
assert period.period_end == date(2025, 1, 31)
|
||||
assert period.total_costs == Decimal('100.50')
|
||||
|
||||
def test_create_period_success(self, period_manager, sample_period_data):
|
||||
"""Test successful period creation."""
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end'],
|
||||
period_type=sample_period_data['period_type']
|
||||
)
|
||||
|
||||
assert period_id is not None
|
||||
assert isinstance(period_id, int)
|
||||
|
||||
# Verify period was created
|
||||
created_period = period_manager.get_period_by_id(period_id)
|
||||
assert created_period is not None
|
||||
assert created_period['period_start'] == sample_period_data['period_start'].isoformat()
|
||||
assert created_period['period_end'] == sample_period_data['period_end'].isoformat()
|
||||
assert created_period['status'] == PeriodStatus.OPEN.value
|
||||
|
||||
def test_create_period_invalid_dates(self, period_manager):
|
||||
"""Test period creation with invalid date range."""
|
||||
with pytest.raises(ValueError, match="Period end date must be after start date"):
|
||||
period_manager.create_period(
|
||||
period_start=date(2025, 1, 31),
|
||||
period_end=date(2025, 1, 1) # End before start
|
||||
)
|
||||
|
||||
def test_create_period_with_loss_carried_forward(self, period_manager, sample_period_data):
|
||||
"""Test period creation with loss carried forward."""
|
||||
loss_amount = Decimal('25.50')
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end'],
|
||||
loss_carried_forward=loss_amount
|
||||
)
|
||||
|
||||
created_period = period_manager.get_period_by_id(period_id)
|
||||
assert created_period['loss_carried_forward'] == loss_amount
|
||||
|
||||
def test_find_overlapping_periods(self, period_manager, sample_period_data):
|
||||
"""Test overlap detection functionality."""
|
||||
# Create first period
|
||||
period_id1 = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Test overlapping period detection
|
||||
overlapping = period_manager.find_overlapping_periods(
|
||||
period_start=date(2025, 1, 15), # Overlaps with existing
|
||||
period_end=date(2025, 2, 15)
|
||||
)
|
||||
|
||||
assert len(overlapping) == 1
|
||||
assert overlapping[0]['id'] == period_id1
|
||||
|
||||
def test_create_overlapping_period_fails(self, period_manager, sample_period_data):
|
||||
"""Test that creating overlapping periods fails."""
|
||||
# Create first period
|
||||
period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Try to create overlapping period
|
||||
with pytest.raises(ValueError, match="Period overlaps with existing periods"):
|
||||
period_manager.create_period(
|
||||
period_start=date(2025, 1, 15), # Overlaps
|
||||
period_end=date(2025, 2, 15)
|
||||
)
|
||||
|
||||
def test_update_period_status_valid_transition(self, period_manager, sample_period_data):
|
||||
"""Test valid period status transitions."""
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Transition from OPEN to CALCULATING
|
||||
success = period_manager.update_period_status(period_id, PeriodStatus.CALCULATING.value)
|
||||
assert success is True
|
||||
|
||||
updated_period = period_manager.get_period_by_id(period_id)
|
||||
assert updated_period['status'] == PeriodStatus.CALCULATING.value
|
||||
|
||||
# Transition from CALCULATING to CLOSED
|
||||
success = period_manager.update_period_status(period_id, PeriodStatus.CLOSED.value)
|
||||
assert success is True
|
||||
|
||||
updated_period = period_manager.get_period_by_id(period_id)
|
||||
assert updated_period['status'] == PeriodStatus.CLOSED.value
|
||||
|
||||
def test_update_period_status_invalid_status(self, period_manager, sample_period_data):
|
||||
"""Test update with invalid status."""
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid status 'invalid'"):
|
||||
period_manager.update_period_status(period_id, 'invalid')
|
||||
|
||||
def test_update_period_status_nonexistent_period(self, period_manager):
|
||||
"""Test update status for non-existent period."""
|
||||
with pytest.raises(ValueError, match="Period #999 not found"):
|
||||
period_manager.update_period_status(999, PeriodStatus.CALCULATING.value)
|
||||
|
||||
def test_calculate_period_costs(self, period_manager, sample_period_data, temp_db):
|
||||
"""Test period cost calculation functionality."""
|
||||
# Create period
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Set up cost manager and add test data
|
||||
finance_models = FinanceModels(temp_db)
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
|
||||
# Get categories
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
software_cat = cost_manager.get_category_by_name('Software')
|
||||
|
||||
# Create test cost items
|
||||
monthly_item = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Monthly Server',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('25.00'),
|
||||
starting_from_date=date(2024, 12, 1) # Started before period
|
||||
)
|
||||
|
||||
one_time_item = CostItem(
|
||||
category_id=software_cat['id'],
|
||||
name='One-time License',
|
||||
cost_type='one_time',
|
||||
amount_eur=Decimal('50.00'),
|
||||
starting_from_date=date(2025, 1, 15) # Within period
|
||||
)
|
||||
|
||||
cost_manager.create_cost_item(monthly_item)
|
||||
cost_manager.create_cost_item(one_time_item)
|
||||
|
||||
# Calculate period costs
|
||||
calculation_result = period_manager.calculate_period_costs(period_id)
|
||||
|
||||
# Verify calculation results
|
||||
assert calculation_result['period_id'] == period_id
|
||||
assert calculation_result['monthly_costs'] == 25.0
|
||||
assert calculation_result['one_time_costs'] == 50.0
|
||||
assert calculation_result['total_costs'] == 75.0
|
||||
|
||||
# Verify period was updated
|
||||
updated_period = period_manager.get_period_by_id(period_id)
|
||||
assert updated_period['total_costs'] == Decimal('75.00')
|
||||
|
||||
def test_close_period(self, period_manager, sample_period_data):
|
||||
"""Test period closure functionality."""
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Close the period
|
||||
success = period_manager.close_period(period_id)
|
||||
assert success is True
|
||||
|
||||
# Verify period is closed
|
||||
closed_period = period_manager.get_period_by_id(period_id)
|
||||
assert closed_period['status'] == PeriodStatus.CLOSED.value
|
||||
|
||||
def test_close_period_already_closed(self, period_manager, sample_period_data):
|
||||
"""Test closing an already closed period."""
|
||||
period_id = period_manager.create_period(
|
||||
period_start=sample_period_data['period_start'],
|
||||
period_end=sample_period_data['period_end']
|
||||
)
|
||||
|
||||
# Close period first time
|
||||
period_manager.close_period(period_id)
|
||||
|
||||
# Close again (should succeed without error)
|
||||
success = period_manager.close_period(period_id)
|
||||
assert success is True
|
||||
|
||||
def test_close_nonexistent_period(self, period_manager):
|
||||
"""Test closing non-existent period."""
|
||||
with pytest.raises(ValueError, match="Period #999 not found"):
|
||||
period_manager.close_period(999)
|
||||
|
||||
def test_list_periods_no_filter(self, period_manager, sample_period_data):
|
||||
"""Test listing all periods without filters."""
|
||||
# Create multiple periods
|
||||
period_id1 = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
period_id2 = period_manager.create_period(
|
||||
period_start=date(2025, 2, 1),
|
||||
period_end=date(2025, 2, 28)
|
||||
)
|
||||
|
||||
# List all periods
|
||||
periods = period_manager.list_periods()
|
||||
|
||||
assert len(periods) == 2
|
||||
period_ids = [p['id'] for p in periods]
|
||||
assert period_id1 in period_ids
|
||||
assert period_id2 in period_ids
|
||||
|
||||
def test_list_periods_with_status_filter(self, period_manager):
|
||||
"""Test listing periods with status filter."""
|
||||
# Create periods with different statuses
|
||||
period_id1 = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
period_id2 = period_manager.create_period(
|
||||
period_start=date(2025, 2, 1),
|
||||
period_end=date(2025, 2, 28)
|
||||
)
|
||||
|
||||
# Close one period
|
||||
period_manager.close_period(period_id2)
|
||||
|
||||
# Filter by open status
|
||||
open_periods = period_manager.list_periods(status_filter=PeriodStatus.OPEN.value)
|
||||
assert len(open_periods) == 1
|
||||
assert open_periods[0]['id'] == period_id1
|
||||
|
||||
# Filter by closed status
|
||||
closed_periods = period_manager.list_periods(status_filter=PeriodStatus.CLOSED.value)
|
||||
assert len(closed_periods) == 1
|
||||
assert closed_periods[0]['id'] == period_id2
|
||||
|
||||
def test_list_periods_with_date_filters(self, period_manager):
|
||||
"""Test listing periods with date range filters."""
|
||||
# Create periods in different months
|
||||
jan_period = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
feb_period = period_manager.create_period(
|
||||
period_start=date(2025, 2, 1),
|
||||
period_end=date(2025, 2, 28)
|
||||
)
|
||||
|
||||
# Filter by start date
|
||||
periods_from_feb = period_manager.list_periods(start_date=date(2025, 2, 1))
|
||||
assert len(periods_from_feb) == 1
|
||||
assert periods_from_feb[0]['id'] == feb_period
|
||||
|
||||
# Filter by end date
|
||||
periods_until_jan = period_manager.list_periods(end_date=date(2025, 1, 31))
|
||||
assert len(periods_until_jan) == 1
|
||||
assert periods_until_jan[0]['id'] == jan_period
|
||||
|
||||
def test_get_current_period(self, period_manager):
|
||||
"""Test getting current period for a specific date."""
|
||||
# Create period covering January 2025
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31)
|
||||
)
|
||||
|
||||
# Test date within period
|
||||
current = period_manager.get_current_period(date(2025, 1, 15))
|
||||
assert current is not None
|
||||
assert current['id'] == period_id
|
||||
|
||||
# Test date outside period
|
||||
current = period_manager.get_current_period(date(2025, 2, 15))
|
||||
assert current is None
|
||||
|
||||
def test_get_current_period_defaults_to_today(self, period_manager):
|
||||
"""Test that get_current_period defaults to today's date."""
|
||||
today = date.today()
|
||||
|
||||
# Create period covering today
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(today.year, today.month, 1),
|
||||
period_end=date(today.year, today.month, 31) if today.month != 12
|
||||
else date(today.year, 12, 31)
|
||||
)
|
||||
|
||||
# Get current period without specifying date
|
||||
current = period_manager.get_current_period()
|
||||
assert current is not None
|
||||
assert current['id'] == period_id
|
||||
|
||||
def test_create_monthly_period(self, period_manager):
|
||||
"""Test convenience method for creating monthly periods."""
|
||||
period_id = period_manager.create_monthly_period(2025, 3)
|
||||
assert period_id is not None
|
||||
|
||||
# Verify correct dates were set
|
||||
period = period_manager.get_period_by_id(period_id)
|
||||
assert period['period_start'] == '2025-03-01'
|
||||
assert period['period_end'] == '2025-03-31'
|
||||
assert period['period_type'] == 'monthly'
|
||||
|
||||
def test_create_monthly_period_december(self, period_manager):
|
||||
"""Test creating monthly period for December (year boundary)."""
|
||||
period_id = period_manager.create_monthly_period(2025, 12)
|
||||
|
||||
period = period_manager.get_period_by_id(period_id)
|
||||
assert period['period_start'] == '2025-12-01'
|
||||
assert period['period_end'] == '2025-12-31'
|
||||
|
||||
def test_auto_create_period_for_date(self, period_manager):
|
||||
"""Test automatic period creation for a given date."""
|
||||
test_date = date(2025, 5, 15)
|
||||
|
||||
# First call should create new period
|
||||
period_id = period_manager.auto_create_period_for_date(test_date)
|
||||
assert period_id is not None
|
||||
|
||||
# Second call should return existing period
|
||||
period_id2 = period_manager.auto_create_period_for_date(test_date)
|
||||
assert period_id2 == period_id
|
||||
|
||||
# Verify period covers the test date
|
||||
period = period_manager.get_period_by_id(period_id)
|
||||
assert period['period_start'] == '2025-05-01'
|
||||
assert period['period_end'] == '2025-05-31'
|
||||
|
||||
def test_period_calculation_with_loss_carried_forward(self, period_manager, temp_db):
|
||||
"""Test period calculation including loss carried forward."""
|
||||
# Create period with loss carried forward
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
loss_carried_forward=Decimal('15.75')
|
||||
)
|
||||
|
||||
# Add a cost item
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
|
||||
cost_item = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Test Server',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('10.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
# Calculate costs
|
||||
calculation = period_manager.calculate_period_costs(period_id)
|
||||
|
||||
# Should include loss carried forward
|
||||
assert calculation['loss_carried_forward'] == 15.75
|
||||
assert calculation['monthly_costs'] == 10.0
|
||||
assert calculation['total_costs'] == 25.75 # 10.0 + 15.75
|
||||
|
||||
def test_period_cost_calculation_edge_cases(self, period_manager, temp_db):
|
||||
"""Test period cost calculation with various edge cases."""
|
||||
# Create period
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(2025, 3, 1),
|
||||
period_end=date(2025, 3, 31)
|
||||
)
|
||||
|
||||
cost_manager = CostItemManager(temp_db)
|
||||
infra_cat = cost_manager.get_category_by_name('Infrastructure')
|
||||
|
||||
# Item that starts before period and ends during period
|
||||
item1 = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Ending Item',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('20.00'),
|
||||
starting_from_date=date(2025, 1, 1),
|
||||
ending_date=date(2025, 3, 15)
|
||||
)
|
||||
|
||||
# Item that starts after period
|
||||
item2 = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Future Item',
|
||||
cost_type='monthly',
|
||||
amount_eur=Decimal('30.00'),
|
||||
starting_from_date=date(2025, 4, 1)
|
||||
)
|
||||
|
||||
# One-time item outside period
|
||||
item3 = CostItem(
|
||||
category_id=infra_cat['id'],
|
||||
name='Past One-time',
|
||||
cost_type='one_time',
|
||||
amount_eur=Decimal('100.00'),
|
||||
starting_from_date=date(2025, 2, 15)
|
||||
)
|
||||
|
||||
cost_manager.create_cost_item(item1)
|
||||
cost_manager.create_cost_item(item2)
|
||||
cost_manager.create_cost_item(item3)
|
||||
|
||||
# Calculate costs
|
||||
calculation = period_manager.calculate_period_costs(period_id)
|
||||
|
||||
# Only item1 should be included (ends during period)
|
||||
assert calculation['monthly_costs'] == 20.0
|
||||
assert calculation['one_time_costs'] == 0.0
|
||||
assert calculation['total_costs'] == 20.0
|
||||
|
||||
def test_error_handling_database_errors(self, period_manager):
|
||||
"""Test error handling for database-related issues."""
|
||||
# Test with invalid period ID
|
||||
with pytest.raises(ValueError, match="Period #-1 not found"):
|
||||
period_manager.calculate_period_costs(-1)
|
||||
|
||||
# Test getting non-existent period
|
||||
result = period_manager.get_period_by_id(99999)
|
||||
assert result is None
|
||||
@@ -1,333 +0,0 @@
|
||||
"""
|
||||
Tests for query paradigm system - Issue #62
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from markitect.query_paradigms.registry import registry
|
||||
from markitect.query_paradigms.base import BaseQueryParadigm, QueryResult
|
||||
from markitect.query_paradigms.paradigms.sql_paradigm import SQLQueryParadigm
|
||||
from markitect.query_paradigms.paradigms.fts_paradigm import FullTextSearchParadigm
|
||||
from markitect.query_paradigms.paradigms.qbe_paradigm import QueryByExampleParadigm
|
||||
|
||||
|
||||
class TestQueryParadigmRegistry:
|
||||
"""Test the query paradigm registry system."""
|
||||
|
||||
def test_registry_has_paradigms(self):
|
||||
"""Test that paradigms are automatically registered."""
|
||||
paradigms = registry.list_all()
|
||||
assert len(paradigms) >= 14 # We expect at least 14 paradigms
|
||||
|
||||
# Check that key paradigms are present
|
||||
paradigm_names = [p.name for p in paradigms]
|
||||
assert "SQL" in paradigm_names
|
||||
assert "FTS" in paradigm_names
|
||||
assert "GraphQL" in paradigm_names
|
||||
assert "Natural Language" in paradigm_names
|
||||
|
||||
def test_get_paradigm_by_name(self):
|
||||
"""Test retrieving paradigms by name."""
|
||||
sql_paradigm = registry.get("SQL")
|
||||
assert sql_paradigm is not None
|
||||
assert sql_paradigm.name == "SQL"
|
||||
assert sql_paradigm.category == "structural"
|
||||
|
||||
# Test case insensitive lookup
|
||||
fts_paradigm = registry.get("fts")
|
||||
assert fts_paradigm is not None
|
||||
assert fts_paradigm.name == "FTS"
|
||||
|
||||
def test_get_nonexistent_paradigm(self):
|
||||
"""Test getting a paradigm that doesn't exist."""
|
||||
result = registry.get("NonExistentParadigm")
|
||||
assert result is None
|
||||
|
||||
def test_list_by_category(self):
|
||||
"""Test filtering paradigms by category."""
|
||||
structural = registry.list_by_category("structural")
|
||||
assert len(structural) > 0
|
||||
|
||||
for paradigm in structural:
|
||||
assert paradigm.category == "structural"
|
||||
|
||||
textual = registry.list_by_category("textual")
|
||||
assert len(textual) > 0
|
||||
|
||||
for paradigm in textual:
|
||||
assert paradigm.category == "textual"
|
||||
|
||||
def test_list_by_complexity(self):
|
||||
"""Test filtering paradigms by complexity."""
|
||||
beginner = registry.list_by_complexity("beginner")
|
||||
assert len(beginner) > 0
|
||||
|
||||
for paradigm in beginner:
|
||||
assert paradigm.complexity == "beginner"
|
||||
|
||||
def test_search_paradigms(self):
|
||||
"""Test searching paradigms by query."""
|
||||
# Search by name
|
||||
sql_results = registry.search_paradigms("SQL")
|
||||
assert len(sql_results) > 0
|
||||
assert any(p.name == "SQL" for p in sql_results)
|
||||
|
||||
# Search by description
|
||||
visual_results = registry.search_paradigms("visual")
|
||||
assert len(visual_results) > 0
|
||||
assert any("visual" in p.description.lower() for p in visual_results)
|
||||
|
||||
# Search for non-existent term
|
||||
empty_results = registry.search_paradigms("xyznonexistent")
|
||||
assert len(empty_results) == 0
|
||||
|
||||
def test_get_categories(self):
|
||||
"""Test getting all available categories."""
|
||||
categories = registry.get_categories()
|
||||
assert isinstance(categories, list)
|
||||
assert len(categories) > 0
|
||||
assert "structural" in categories
|
||||
assert "textual" in categories
|
||||
assert "semantic" in categories
|
||||
|
||||
def test_get_complexity_levels(self):
|
||||
"""Test getting all complexity levels."""
|
||||
levels = registry.get_complexity_levels()
|
||||
assert isinstance(levels, list)
|
||||
assert len(levels) > 0
|
||||
assert "beginner" in levels
|
||||
assert "intermediate" in levels
|
||||
assert "advanced" in levels
|
||||
|
||||
|
||||
class TestSQLParadigm:
|
||||
"""Test the SQL query paradigm."""
|
||||
|
||||
def test_paradigm_properties(self):
|
||||
"""Test SQL paradigm basic properties."""
|
||||
paradigm = SQLQueryParadigm()
|
||||
assert paradigm.name == "SQL"
|
||||
assert paradigm.category == "structural"
|
||||
assert paradigm.complexity == "intermediate"
|
||||
assert "database" in paradigm.description.lower()
|
||||
|
||||
def test_validate_query(self):
|
||||
"""Test SQL query validation."""
|
||||
paradigm = SQLQueryParadigm()
|
||||
|
||||
# Valid queries
|
||||
valid, error = paradigm.validate_query("SELECT * FROM files")
|
||||
assert valid
|
||||
assert error is None
|
||||
|
||||
valid, error = paradigm.validate_query("SELECT name FROM files WHERE author = 'Alice'")
|
||||
assert valid
|
||||
|
||||
# Invalid queries
|
||||
valid, error = paradigm.validate_query("")
|
||||
assert not valid
|
||||
assert error is not None
|
||||
|
||||
valid, error = paradigm.validate_query(" ")
|
||||
assert not valid
|
||||
|
||||
def test_get_examples(self):
|
||||
"""Test SQL paradigm examples."""
|
||||
paradigm = SQLQueryParadigm()
|
||||
examples = paradigm.get_examples()
|
||||
|
||||
assert isinstance(examples, list)
|
||||
assert len(examples) > 0
|
||||
|
||||
for example in examples:
|
||||
assert "name" in example
|
||||
assert "description" in example
|
||||
assert "query" in example
|
||||
assert isinstance(example["query"], str)
|
||||
|
||||
def test_get_syntax_help(self):
|
||||
"""Test SQL syntax help."""
|
||||
paradigm = SQLQueryParadigm()
|
||||
help_text = paradigm.get_syntax_help()
|
||||
|
||||
assert isinstance(help_text, str)
|
||||
assert len(help_text) > 0
|
||||
assert "SELECT" in help_text
|
||||
|
||||
|
||||
class TestFTSParadigm:
|
||||
"""Test the Full Text Search paradigm."""
|
||||
|
||||
def test_paradigm_properties(self):
|
||||
"""Test FTS paradigm basic properties."""
|
||||
paradigm = FullTextSearchParadigm()
|
||||
assert paradigm.name == "FTS"
|
||||
assert paradigm.category == "textual"
|
||||
assert paradigm.complexity == "beginner"
|
||||
assert "search" in paradigm.description.lower()
|
||||
|
||||
def test_validate_query(self):
|
||||
"""Test FTS query validation."""
|
||||
paradigm = FullTextSearchParadigm()
|
||||
|
||||
# Valid queries
|
||||
valid, error = paradigm.validate_query("documentation")
|
||||
assert valid
|
||||
assert error is None
|
||||
|
||||
valid, error = paradigm.validate_query("API AND documentation")
|
||||
assert valid
|
||||
|
||||
valid, error = paradigm.validate_query('"getting started"')
|
||||
assert valid
|
||||
|
||||
# Invalid queries
|
||||
valid, error = paradigm.validate_query("")
|
||||
assert not valid
|
||||
assert error is not None
|
||||
|
||||
def test_get_examples(self):
|
||||
"""Test FTS paradigm examples."""
|
||||
paradigm = FullTextSearchParadigm()
|
||||
examples = paradigm.get_examples()
|
||||
|
||||
assert isinstance(examples, list)
|
||||
assert len(examples) > 0
|
||||
|
||||
# Check for expected example types
|
||||
example_names = [ex["name"] for ex in examples]
|
||||
assert "Simple search" in example_names
|
||||
assert "Boolean search" in example_names
|
||||
|
||||
|
||||
class TestQueryByExampleParadigm:
|
||||
"""Test the Query By Example paradigm (documentation-only)."""
|
||||
|
||||
def test_paradigm_properties(self):
|
||||
"""Test QBE paradigm basic properties."""
|
||||
paradigm = QueryByExampleParadigm()
|
||||
assert paradigm.name == "Query By Example"
|
||||
assert paradigm.category == "visual"
|
||||
assert paradigm.complexity == "beginner"
|
||||
assert "template" in paradigm.description.lower()
|
||||
|
||||
def test_validate_query(self):
|
||||
"""Test QBE query validation."""
|
||||
paradigm = QueryByExampleParadigm()
|
||||
|
||||
# Valid JSON templates
|
||||
valid, error = paradigm.validate_query('{"author": "Alice"}')
|
||||
assert valid
|
||||
assert error is None
|
||||
|
||||
valid, error = paradigm.validate_query('{"tags": ["tutorial"], "type": "markdown"}')
|
||||
assert valid
|
||||
|
||||
# Invalid queries
|
||||
valid, error = paradigm.validate_query("")
|
||||
assert not valid
|
||||
assert error is not None
|
||||
|
||||
valid, error = paradigm.validate_query("not json")
|
||||
assert not valid
|
||||
assert "JSON" in error
|
||||
|
||||
valid, error = paradigm.validate_query('["not", "an", "object"]')
|
||||
assert not valid
|
||||
assert "object" in error
|
||||
|
||||
def test_execute_returns_not_implemented(self):
|
||||
"""Test that QBE execution returns not implemented error."""
|
||||
paradigm = QueryByExampleParadigm()
|
||||
result = paradigm.execute('{"author": "Alice"}')
|
||||
|
||||
assert isinstance(result, QueryResult)
|
||||
assert not result.success
|
||||
assert result.error_message is not None
|
||||
assert "not yet implemented" in result.error_message.lower()
|
||||
assert result.metadata["status"] == "not_implemented"
|
||||
|
||||
def test_get_syntax_help(self):
|
||||
"""Test QBE syntax help."""
|
||||
paradigm = QueryByExampleParadigm()
|
||||
help_text = paradigm.get_syntax_help()
|
||||
|
||||
assert isinstance(help_text, str)
|
||||
assert len(help_text) > 0
|
||||
assert "JSON" in help_text
|
||||
assert "template" in help_text.lower()
|
||||
|
||||
|
||||
class TestQueryResult:
|
||||
"""Test the QueryResult data structure."""
|
||||
|
||||
def test_query_result_creation(self):
|
||||
"""Test creating a QueryResult."""
|
||||
result = QueryResult(
|
||||
paradigm="Test",
|
||||
query="test query",
|
||||
execution_time_ms=10.5,
|
||||
result_count=3,
|
||||
results=[{"id": 1}, {"id": 2}, {"id": 3}],
|
||||
metadata={"type": "test"},
|
||||
success=True
|
||||
)
|
||||
|
||||
assert result.paradigm == "Test"
|
||||
assert result.query == "test query"
|
||||
assert result.execution_time_ms == 10.5
|
||||
assert result.result_count == 3
|
||||
assert len(result.results) == 3
|
||||
assert result.metadata["type"] == "test"
|
||||
assert result.success is True
|
||||
assert result.error_message is None
|
||||
|
||||
def test_query_result_with_error(self):
|
||||
"""Test creating a QueryResult with error."""
|
||||
result = QueryResult(
|
||||
paradigm="Test",
|
||||
query="bad query",
|
||||
execution_time_ms=1.0,
|
||||
result_count=0,
|
||||
results=[],
|
||||
metadata={},
|
||||
success=False,
|
||||
error_message="Query failed"
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert result.error_message == "Query failed"
|
||||
assert result.result_count == 0
|
||||
|
||||
|
||||
class TestBaseQueryParadigm:
|
||||
"""Test the base query paradigm interface."""
|
||||
|
||||
def test_cannot_instantiate_base_class(self):
|
||||
"""Test that BaseQueryParadigm cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError):
|
||||
BaseQueryParadigm()
|
||||
|
||||
def test_paradigm_interface(self):
|
||||
"""Test that paradigms implement the required interface."""
|
||||
paradigm = SQLQueryParadigm()
|
||||
|
||||
# Test all required properties
|
||||
assert hasattr(paradigm, 'name')
|
||||
assert hasattr(paradigm, 'description')
|
||||
assert hasattr(paradigm, 'category')
|
||||
assert hasattr(paradigm, 'complexity')
|
||||
|
||||
# Test all required methods
|
||||
assert hasattr(paradigm, 'execute')
|
||||
assert hasattr(paradigm, 'get_examples')
|
||||
assert hasattr(paradigm, 'validate_query')
|
||||
assert hasattr(paradigm, 'get_syntax_help')
|
||||
|
||||
# Test optional methods
|
||||
assert hasattr(paradigm, 'can_translate_from')
|
||||
assert hasattr(paradigm, 'translate_query')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
Reference in New Issue
Block a user