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