diff --git a/backend/api/deps.py b/backend/api/deps.py index 6a968ec..8ced5a5 100644 --- a/backend/api/deps.py +++ b/backend/api/deps.py @@ -10,6 +10,7 @@ from backend.domain.services.abandoned_problems_service import AbandonedProblemsService from backend.domain.services.difficulty_distribution_service import DifficultyDistributionService from backend.domain.services.daily_activity_service import DailyActivityService +from backend.domain.services.division_problems_service import DivisionProblemsService from backend.domain.services.tags_service import TagsService from backend.infrastructure.task_queue import TaskQueue from backend.infrastructure.redis_client import get_redis_client @@ -40,6 +41,11 @@ def get_difficulty_distribution_service() -> DifficultyDistributionService: return DifficultyDistributionService() +def get_division_problems_service() -> DivisionProblemsService: + """Dependency provider for DivisionProblemsService.""" + return DivisionProblemsService() + + async def get_redis() -> Redis: """Dependency provider for Redis client.""" return await get_redis_client() @@ -68,6 +74,9 @@ def get_request_metadata(request: Request) -> Dict[str, Any]: difficulty_distribution_service_dependency = Provide( get_difficulty_distribution_service, sync_to_thread=False ) +division_problems_service_dependency = Provide( + get_division_problems_service, sync_to_thread=False +) tags_service_dependency = Provide(get_tags_service, sync_to_thread=False) request_metadata_dependency = Provide(get_request_metadata, sync_to_thread=False) redis_dependency = Provide(get_redis) diff --git a/backend/api/routes/__init__.py b/backend/api/routes/__init__.py index 24b0ffc..4fdfe61 100644 --- a/backend/api/routes/__init__.py +++ b/backend/api/routes/__init__.py @@ -4,6 +4,7 @@ from backend.api.routes.base import BaseMetricController from backend.api.routes.daily_activity import DailyActivityController from backend.api.routes.difficulty_distribution import DifficultyDistributionController +from backend.api.routes.division_problems import DivisionProblemsController from backend.api.routes.tags import TagsController from backend.api.routes.tasks import TaskController @@ -11,6 +12,7 @@ AbandonedProblemsController, DailyActivityController, DifficultyDistributionController, + DivisionProblemsController, TagsController, TaskController, ] @@ -20,6 +22,7 @@ "BaseMetricController", "DailyActivityController", "DifficultyDistributionController", + "DivisionProblemsController", "TagsController", "TaskController", "routes", diff --git a/backend/api/routes/division_problems.py b/backend/api/routes/division_problems.py new file mode 100644 index 0000000..a90b2d9 --- /dev/null +++ b/backend/api/routes/division_problems.py @@ -0,0 +1,178 @@ +"""Division problems API routes.""" + +import asyncio +from datetime import datetime, timezone +from typing import Union + +from litestar import get +from litestar.params import Parameter +from litestar.response import Response +from litestar.exceptions import HTTPException +from redis.asyncio import Redis + +from backend.api.deps import ( + codeforces_data_service_dependency, + division_problems_service_dependency, + redis_dependency, + task_queue_dependency, +) +from backend.api.routes.base import BaseMetricController +from backend.domain.models.time_period import TimePeriod +from backend.api.schemas.division_problems import ( + DivisionProblemsResponse, + DivisionStatsSchema, +) +from backend.api.schemas.common import AsyncTaskResponse +from backend.domain.services.division_problems_service import DivisionProblemsService +from backend.services.codeforces_data_service import CodeforcesDataService +from backend.infrastructure.codeforces_client import UserNotFoundError +from backend.infrastructure.task_queue import TaskQueue + + +class DivisionProblemsController(BaseMetricController): + """Controller for division problems endpoints.""" + + path = "/division-problems" + tags = ["Division Problems"] + + @get( + path="/{handle:str}", + dependencies={ + "data_service": codeforces_data_service_dependency, + "division_service": division_problems_service_dependency, + "redis": redis_dependency, + "task_queue": task_queue_dependency, + }, + ) + async def get_division_problems( + self, + handle: str, + data_service: CodeforcesDataService, + division_service: DivisionProblemsService, + redis: Redis, + task_queue: TaskQueue, + period: TimePeriod = Parameter( + default=TimePeriod.ALL_TIME, + description="Time period to filter submissions by", + ), + prefer_fresh: bool = Parameter( + default=False, + description="If true, force refresh even if stale data is available", + ), + ) -> Union[Response[DivisionProblemsResponse], Response[AsyncTaskResponse]]: + """ + Get user's average problems solved per division. + + Returns statistics showing the average number of problems solved per contest + for each division (Div. 1, Div. 2, Div. 3, Div. 4). This helps identify + performance across different difficulty levels of contests. + + Args: + handle: Codeforces handle + period: Time period to filter submissions by + prefer_fresh: Force refresh even if stale data exists + + Returns: + Division problems analysis with averages per division + OR 202 Accepted with task_id if data needs to be fetched + """ + # Get contest divisions mapping + contest_divisions = await data_service.get_contest_divisions() + + # Get submissions with staleness check + submissions, age, is_stale = await self.get_submissions_with_staleness(redis, handle) + + # Case 1: Fresh data (< 4 hours) + if submissions and not is_stale: + submissions = self._filter_by_date_range( + submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc)) + ) + analysis = division_service.analyze_division_problems( + handle, submissions, contest_divisions + ) + + divisions = [ + DivisionStatsSchema.model_validate(div) for div in analysis.divisions + ] + + response = DivisionProblemsResponse( + divisions=divisions, + total_contests=analysis.total_contests, + total_problems_solved=analysis.total_problems_solved, + last_updated=self.get_current_timestamp(), + ) + + return Response(response, headers=self._cache_headers(14400 - age)) + + # Case 2: Stale data (4-24 hours) and !prefer_fresh + if submissions and is_stale and not prefer_fresh: + # Return stale data immediately + submissions = self._filter_by_date_range( + submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc)) + ) + analysis = division_service.analyze_division_problems( + handle, submissions, contest_divisions + ) + + divisions = [ + DivisionStatsSchema.model_validate(div) for div in analysis.divisions + ] + + response = DivisionProblemsResponse( + divisions=divisions, + total_contests=analysis.total_contests, + total_problems_solved=analysis.total_problems_solved, + last_updated=self.get_current_timestamp(), + ) + + # Enqueue background refresh (non-blocking) + asyncio.create_task(task_queue.enqueue(handle)) + + return Response( + response, + headers={ + **self._cache_headers(0), + "X-Data-Stale": "true", + "X-Data-Age": str(age), + }, + ) + + # Case 3: No data or prefer_fresh + try: + task_id = await task_queue.enqueue(handle) + return Response( + content=AsyncTaskResponse( + status="processing", task_id=task_id, retry_after=2 + ).model_dump(), + status_code=202, + ) + except Exception: + # Fallback: try fetching directly if queue fails + try: + submissions = await data_service.get_user_submissions(handle) + except UserNotFoundError: + raise HTTPException( + status_code=404, detail=f"User '{handle}' not found on Codeforces" + ) + + submissions = self._filter_by_date_range( + submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc)) + ) + self._validate_submissions_exist(submissions, handle) + + analysis = division_service.analyze_division_problems( + handle, submissions, contest_divisions + ) + + divisions = [ + DivisionStatsSchema.model_validate(div) for div in analysis.divisions + ] + + response = DivisionProblemsResponse( + divisions=divisions, + total_contests=analysis.total_contests, + total_problems_solved=analysis.total_problems_solved, + last_updated=self.get_current_timestamp(), + ) + + return Response(response, headers=self._cache_headers(14400)) diff --git a/backend/api/schemas/division_problems.py b/backend/api/schemas/division_problems.py new file mode 100644 index 0000000..69f99fe --- /dev/null +++ b/backend/api/schemas/division_problems.py @@ -0,0 +1,36 @@ +"""Division problems API schemas.""" + +from datetime import datetime +from typing import List + +from pydantic import Field + +from backend.api.schemas.base import BaseAPISchema + + +class DivisionStatsSchema(BaseAPISchema): + """Schema for statistics of a single division.""" + + division: str = Field(..., description="Division name (e.g., 'Div. 1', 'Div. 2')") + contest_count: int = Field(..., description="Number of contests participated in this division") + total_problems_solved: int = Field( + ..., description="Total problems solved in this division" + ) + average_problems_per_contest: float = Field( + ..., description="Average number of problems solved per contest in this division" + ) + + +class DivisionProblemsResponse(BaseAPISchema): + """Response schema for division problems analysis.""" + + divisions: List[DivisionStatsSchema] = Field( + ..., description="Statistics for each division" + ) + total_contests: int = Field( + ..., description="Total number of rated contests participated" + ) + total_problems_solved: int = Field( + ..., description="Total number of problems solved across all divisions" + ) + last_updated: datetime = Field(..., description="Timestamp when data was last fetched") diff --git a/backend/domain/models/__init__.py b/backend/domain/models/__init__.py index 5d3f577..dbe53dc 100644 --- a/backend/domain/models/__init__.py +++ b/backend/domain/models/__init__.py @@ -10,6 +10,7 @@ ) from .daily_activity import DailyActivity, DailyActivityAnalysis from .difficulty_distribution import DifficultyDistribution, RatingRange +from .division_problems import DivisionProblemsAnalysis, DivisionStats from .time_period import TimePeriod __all__ = [ @@ -19,6 +20,8 @@ "DailyActivity", "DailyActivityAnalysis", "DifficultyDistribution", + "DivisionProblemsAnalysis", + "DivisionStats", "Problem", "RatingAbandonedStats", "RatingRange", diff --git a/backend/domain/models/division_problems.py b/backend/domain/models/division_problems.py new file mode 100644 index 0000000..a44b458 --- /dev/null +++ b/backend/domain/models/division_problems.py @@ -0,0 +1,26 @@ +"""Division problems domain models.""" + +from dataclasses import dataclass +from typing import List + +from backend.domain.models.base import BaseDomainModel + + +@dataclass +class DivisionStats(BaseDomainModel): + """Statistics for a single division.""" + + division: str + contest_count: int + total_problems_solved: int + average_problems_per_contest: float + + +@dataclass +class DivisionProblemsAnalysis(BaseDomainModel): + """Analysis of average problems solved per division.""" + + handle: str + divisions: List[DivisionStats] + total_contests: int + total_problems_solved: int diff --git a/backend/domain/services/__init__.py b/backend/domain/services/__init__.py index 7bc70fa..3753b1c 100644 --- a/backend/domain/services/__init__.py +++ b/backend/domain/services/__init__.py @@ -4,6 +4,7 @@ from .abandoned_problems_service import AbandonedProblemsService from .daily_activity_service import DailyActivityService from .difficulty_distribution_service import DifficultyDistributionService +from .division_problems_service import DivisionProblemsService from .tags_service import TagsService __all__ = [ @@ -11,5 +12,6 @@ "BaseMetricService", "DailyActivityService", "DifficultyDistributionService", + "DivisionProblemsService", "TagsService", ] diff --git a/backend/domain/services/division_problems_service.py b/backend/domain/services/division_problems_service.py new file mode 100644 index 0000000..55a242c --- /dev/null +++ b/backend/domain/services/division_problems_service.py @@ -0,0 +1,118 @@ +"""Division problems service for analyzing average problems per division.""" + +from collections import defaultdict +from typing import Dict, List + +from backend.domain.models.codeforces import Submission +from backend.domain.models.division_problems import ( + DivisionProblemsAnalysis, + DivisionStats, +) +from backend.domain.services.base import BaseMetricService + + +class DivisionProblemsService(BaseMetricService): + """Service for generating division problems analytics.""" + + # Standard divisions on Codeforces + DIVISIONS = ["Div. 1", "Div. 2", "Div. 3", "Div. 4"] + + @staticmethod + def analyze_division_problems( + handle: str, + submissions: List[Submission], + contest_divisions: Dict[int, str | None], + ) -> DivisionProblemsAnalysis: + """ + Analyze user's solved problems and calculate average per division. + + Args: + handle: Codeforces handle + submissions: List of user's submissions + contest_divisions: Mapping of contest_id to division string + + Returns: + DivisionProblemsAnalysis with average problems per division + """ + # Filter successful submissions + successful_submissions = DivisionProblemsService._filter_successful_submissions( + submissions + ) + + if not successful_submissions: + return DivisionProblemsAnalysis( + handle=handle, + divisions=[], + total_contests=0, + total_problems_solved=0, + ) + + # Remove duplicate problems (keep first solve) + unique_solves = DivisionProblemsService._deduplicate_problems(successful_submissions) + + # Group problems by contest and division + division_data = DivisionProblemsService._analyze_by_division( + unique_solves, contest_divisions + ) + + # Build division stats + divisions = [] + total_contests = 0 + total_problems = 0 + + for div in DivisionProblemsService.DIVISIONS: + if div in division_data: + stats = division_data[div] + contest_count = len(stats["contests"]) + problems_solved = stats["problem_count"] + + if contest_count > 0: + avg_problems = round(problems_solved / contest_count, 2) + divisions.append( + DivisionStats( + division=div, + contest_count=contest_count, + total_problems_solved=problems_solved, + average_problems_per_contest=avg_problems, + ) + ) + total_contests += contest_count + total_problems += problems_solved + + return DivisionProblemsAnalysis( + handle=handle, + divisions=divisions, + total_contests=total_contests, + total_problems_solved=total_problems, + ) + + @staticmethod + def _analyze_by_division( + submissions: List[Submission], + contest_divisions: Dict[int, str | None], + ) -> Dict[str, Dict]: + """ + Group submissions by division and count problems per contest. + + Args: + submissions: List of successful submissions + contest_divisions: Mapping of contest_id to division + + Returns: + Dictionary with division -> {contests: set, problem_count: int} + """ + division_data: Dict[str, Dict] = defaultdict( + lambda: {"contests": set(), "problem_count": 0} + ) + + for submission in submissions: + contest_id = submission.contest_id + division = contest_divisions.get(contest_id) + + if division is None: + continue + + division_data[division]["contests"].add(contest_id) + division_data[division]["problem_count"] += 1 + + return dict(division_data) diff --git a/backend/infrastructure/codeforces_client.py b/backend/infrastructure/codeforces_client.py index 5ae8483..91e8361 100644 --- a/backend/infrastructure/codeforces_client.py +++ b/backend/infrastructure/codeforces_client.py @@ -1,6 +1,7 @@ """Codeforces API client for BetterForces.""" import httpx +import re from typing import List, Dict, Any import json from backend.config import settings @@ -21,6 +22,10 @@ class UserNotFoundError(Exception): pass +# Global cache for contest division mapping +_contest_division_cache: Dict[int, str | None] = {} + + class CodeforcesClient: """Client for interacting with Codeforces API.""" @@ -127,3 +132,90 @@ def _parse_submissions(self, raw_submissions: List[Dict[str, Any]]) -> List[Subm continue return submissions + + async def get_contests(self) -> List[Dict[str, Any]]: + """ + Fetch all contests from Codeforces. + + Returns: + List of contest data dictionaries + + Raises: + CodeforcesAPIError: If API request fails + """ + url = f"{self.base_url}/contest.list" + + try: + response = await self.http_client.get(url) + data = response.json() + + status = data.get("status") + if status != "OK": + raise CodeforcesAPIError( + f"API returned status: {status}", response.status_code + ) + + return data.get("result", []) + + except httpx.HTTPStatusError as e: + raise CodeforcesAPIError(f"HTTP error {e.response.status_code}: {e.response.text}") + except httpx.RequestError as e: + raise CodeforcesAPIError(f"Request error: {str(e)}") + except json.JSONDecodeError as e: + raise CodeforcesAPIError(f"JSON decode error: {str(e)}") + + async def get_contest_divisions(self) -> Dict[int, str | None]: + """ + Fetch all contests and extract their division information. + + Returns: + Dictionary mapping contest_id to division string (e.g., "Div. 1", "Div. 2") + + Raises: + CodeforcesAPIError: If API request fails + """ + global _contest_division_cache + + # Return cached data if available + if _contest_division_cache: + return _contest_division_cache + + contests = await self.get_contests() + + for contest in contests: + contest_id = contest.get("id") + contest_name = contest.get("name", "") + + if contest_id is not None: + division = self._extract_division(contest_name) + _contest_division_cache[contest_id] = division + + return _contest_division_cache + + @staticmethod + def _extract_division(contest_name: str) -> str | None: + """ + Extract division from contest name. + + Args: + contest_name: Name of the contest (e.g., "Codeforces Round 123 (Div. 2)") + + Returns: + Division string (e.g., "Div. 1", "Div. 2", "Div. 3", "Div. 4") or None + """ + # Match patterns like "Div. 1", "Div. 2", "Div. 3", "Div. 4" + # Also handle "(Div. 1 + Div. 2)" combined rounds + patterns = [ + r"\(Div\.\s*([1-4])\s*\+\s*Div\.\s*([1-4])\)", # Combined divisions + r"Div\.\s*([1-4])", # Single division + ] + + for pattern in patterns: + match = re.search(pattern, contest_name, re.IGNORECASE) + if match: + if len(match.groups()) == 2: + # Combined division - return the higher div (lower number = harder) + return f"Div. {min(match.group(1), match.group(2))}" + return f"Div. {match.group(1)}" + + return None diff --git a/backend/services/codeforces_data_service.py b/backend/services/codeforces_data_service.py index 845f2d3..77aedfb 100644 --- a/backend/services/codeforces_data_service.py +++ b/backend/services/codeforces_data_service.py @@ -1,6 +1,6 @@ """Codeforces data service for BetterForces.""" -from typing import List +from typing import Dict, List from backend.infrastructure.codeforces_client import CodeforcesClient, UserNotFoundError from backend.domain.models.codeforces import Submission @@ -28,3 +28,13 @@ async def get_user_submissions(self, handle: str) -> List[Submission]: except UserNotFoundError: # Re-raise to be caught by controllers raise + + async def get_contest_divisions(self) -> Dict[int, str | None]: + """ + Get mapping of contest IDs to their division. + + Returns: + Dictionary mapping contest_id to division string + """ + async with self.codeforces_client as client: + return await client.get_contest_divisions() diff --git a/backend/tests/unit/codeforces_client/test_extract_division.py b/backend/tests/unit/codeforces_client/test_extract_division.py new file mode 100644 index 0000000..7f13cf7 --- /dev/null +++ b/backend/tests/unit/codeforces_client/test_extract_division.py @@ -0,0 +1,61 @@ +"""Tests for division extraction from contest names.""" + +from backend.infrastructure.codeforces_client import CodeforcesClient + + +def test_extracts_div_1() -> None: + assert CodeforcesClient._extract_division("Codeforces Round 123 (Div. 1)") == "Div. 1" + + +def test_extracts_div_2() -> None: + assert CodeforcesClient._extract_division("Codeforces Round 456 (Div. 2)") == "Div. 2" + + +def test_extracts_div_3() -> None: + assert CodeforcesClient._extract_division("Codeforces Round 789 (Div. 3)") == "Div. 3" + + +def test_extracts_div_4() -> None: + assert CodeforcesClient._extract_division("Codeforces Round 999 (Div. 4)") == "Div. 4" + + +def test_extracts_combined_div_1_and_2() -> None: + # For combined rounds, should return the harder division (lower number) + assert CodeforcesClient._extract_division("Codeforces Round 100 (Div. 1 + Div. 2)") == "Div. 1" + + +def test_extracts_combined_div_2_and_1() -> None: + # Order shouldn't matter + assert CodeforcesClient._extract_division("Codeforces Round 100 (Div. 2 + Div. 1)") == "Div. 1" + + +def test_returns_none_for_educational_round() -> None: + assert CodeforcesClient._extract_division("Educational Codeforces Round 123") is None + + +def test_returns_none_for_global_round() -> None: + assert CodeforcesClient._extract_division("Codeforces Global Round 20") is None + + +def test_extracts_with_different_spacing() -> None: + assert CodeforcesClient._extract_division("Codeforces Round 123 (Div.2)") == "Div. 2" + assert CodeforcesClient._extract_division("Codeforces Round 123 (Div. 2 )") == "Div. 2" + + +def test_case_insensitive_extraction() -> None: + assert CodeforcesClient._extract_division("Contest (div. 2)") == "Div. 2" + assert CodeforcesClient._extract_division("Contest (DIV. 2)") == "Div. 2" + + +def test_returns_none_for_empty_string() -> None: + assert CodeforcesClient._extract_division("") is None + + +def test_extracts_from_complex_contest_name() -> None: + name = "Codeforces Round #855 (Div. 1, based on VK Cup 2023 - Elimination)" + assert CodeforcesClient._extract_division(name) == "Div. 1" + + +def test_extracts_from_contest_with_multiple_parts() -> None: + name = "Codeforces Round #100500 (Div. 3, sponsored by Some Company)" + assert CodeforcesClient._extract_division(name) == "Div. 3" diff --git a/backend/tests/unit/division_problems_service/__init__.py b/backend/tests/unit/division_problems_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/division_problems_service/conftest.py b/backend/tests/unit/division_problems_service/conftest.py new file mode 100644 index 0000000..b777675 --- /dev/null +++ b/backend/tests/unit/division_problems_service/conftest.py @@ -0,0 +1,56 @@ +import datetime +from typing import Callable, List +from unittest.mock import Mock + +import pytest + +from backend.domain.models.codeforces import Problem, Submission, SubmissionStatus + + +@pytest.fixture +def mock_submission() -> Callable[..., Mock]: + + def _create( + contest_id: int, + index: str, + name: str, + rating: int | None, + tags: List[str], + verdict: SubmissionStatus = SubmissionStatus.OK, + programming_language: str = "Python 3", + is_solved: bool = True, + ) -> Mock: + mock_problem = Mock(spec=Problem) + mock_problem.contest_id = contest_id + mock_problem.index = index + mock_problem.name = name + mock_problem.rating = rating + mock_problem.tags = tags + mock_problem.problem_key = f"{contest_id}{index}" + + mock_submission = Mock(spec=Submission) + mock_submission.contest_id = contest_id + mock_submission.creation_time_seconds = datetime.datetime.now().second + mock_submission.problem = mock_problem + mock_submission.verdict = verdict + mock_submission.programming_language = programming_language + mock_submission.is_solved = is_solved + + return mock_submission + + return _create + + +@pytest.fixture +def sample_contest_divisions() -> dict: + """Sample contest divisions mapping for testing.""" + return { + 100: "Div. 1", + 101: "Div. 1", + 200: "Div. 2", + 201: "Div. 2", + 202: "Div. 2", + 300: "Div. 3", + 400: "Div. 4", + 500: None, # Contest without division + } diff --git a/backend/tests/unit/division_problems_service/test_analysis.py b/backend/tests/unit/division_problems_service/test_analysis.py new file mode 100644 index 0000000..d1e8de1 --- /dev/null +++ b/backend/tests/unit/division_problems_service/test_analysis.py @@ -0,0 +1,283 @@ +from backend.domain.models.codeforces import SubmissionStatus +from backend.domain.services.division_problems_service import DivisionProblemsService + + +def test_returns_empty_divisions_when_no_submissions(sample_contest_divisions) -> None: + result = DivisionProblemsService.analyze_division_problems( + "test_user", [], sample_contest_divisions + ) + + assert result.handle == "test_user" + assert result.divisions == [] + assert result.total_contests == 0 + assert result.total_problems_solved == 0 + + +def test_returns_empty_divisions_when_all_submissions_failed( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + verdict=SubmissionStatus.WRONG_ANSWER, + is_solved=False, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert result.divisions == [] + assert result.total_contests == 0 + assert result.total_problems_solved == 0 + + +def test_calculates_average_for_single_division( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + # Contest 200 (Div. 2) - 2 problems + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=200, + index="B", + name="Problem B", + rating=1000, + tags=["dp"], + is_solved=True, + ), + # Contest 201 (Div. 2) - 3 problems + mock_submission( + contest_id=201, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=201, + index="B", + name="Problem B", + rating=1000, + tags=["dp"], + is_solved=True, + ), + mock_submission( + contest_id=201, + index="C", + name="Problem C", + rating=1200, + tags=["greedy"], + is_solved=True, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert len(result.divisions) == 1 + assert result.divisions[0].division == "Div. 2" + assert result.divisions[0].contest_count == 2 + assert result.divisions[0].total_problems_solved == 5 + assert result.divisions[0].average_problems_per_contest == 2.5 + + +def test_calculates_averages_for_multiple_divisions( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + # Contest 100 (Div. 1) - 1 problem + mock_submission( + contest_id=100, + index="A", + name="Problem A", + rating=2000, + tags=["math"], + is_solved=True, + ), + # Contest 200 (Div. 2) - 3 problems + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=200, + index="B", + name="Problem B", + rating=1000, + tags=["dp"], + is_solved=True, + ), + mock_submission( + contest_id=200, + index="C", + name="Problem C", + rating=1200, + tags=["greedy"], + is_solved=True, + ), + # Contest 300 (Div. 3) - 4 problems + mock_submission( + contest_id=300, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=300, + index="B", + name="Problem B", + rating=900, + tags=["dp"], + is_solved=True, + ), + mock_submission( + contest_id=300, + index="C", + name="Problem C", + rating=1000, + tags=["greedy"], + is_solved=True, + ), + mock_submission( + contest_id=300, + index="D", + name="Problem D", + rating=1100, + tags=["strings"], + is_solved=True, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert len(result.divisions) == 3 + assert result.total_contests == 3 + assert result.total_problems_solved == 8 + + # Find each division + div1 = next(d for d in result.divisions if d.division == "Div. 1") + div2 = next(d for d in result.divisions if d.division == "Div. 2") + div3 = next(d for d in result.divisions if d.division == "Div. 3") + + assert div1.average_problems_per_contest == 1.0 + assert div2.average_problems_per_contest == 3.0 + assert div3.average_problems_per_contest == 4.0 + + +def test_counts_problem_only_once_when_solved_multiple_times( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert len(result.divisions) == 1 + assert result.divisions[0].total_problems_solved == 1 + assert result.divisions[0].average_problems_per_contest == 1.0 + + +def test_ignores_contests_without_division( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + # Contest 500 (no division) + mock_submission( + contest_id=500, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + # Contest 200 (Div. 2) + mock_submission( + contest_id=200, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert len(result.divisions) == 1 + assert result.divisions[0].division == "Div. 2" + assert result.total_problems_solved == 1 + + +def test_ignores_contests_not_in_division_mapping( + mock_submission, sample_contest_divisions +) -> None: + submissions = [ + # Contest 999 (not in mapping) + mock_submission( + contest_id=999, + index="A", + name="Problem A", + rating=800, + tags=["math"], + is_solved=True, + ), + ] + + result = DivisionProblemsService.analyze_division_problems( + "test_user", submissions, sample_contest_divisions + ) + + assert result.divisions == [] + assert result.total_contests == 0 + assert result.total_problems_solved == 0