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

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:
2025-10-25 02:37:45 +02:00
parent f0dfd04d45
commit 096017b93f
23 changed files with 74 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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'])

View File

@@ -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

View File

@@ -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__])

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View 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

View File

@@ -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

View File

@@ -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__])