diff --git a/.gitignore b/.gitignore index 9f93008f..79615e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ docs/module-structure.html .idea/ .vscode/ *.swp +.aider* diff --git a/api/feature1.py b/api/feature1.py new file mode 100644 index 00000000..ad4adb92 --- /dev/null +++ b/api/feature1.py @@ -0,0 +1,18 @@ +class Feature1ListView(generics.ListCreateAPIView): + permission_classes = [HasModuleGrant("elm"), + HasAnyPermission("elm.feature1.view")] + filterset_class = Feature1Filter + + def get_queryset(self): + return selectors.visible_feature1s(self.request.principal) # never Feature1.objects + + def get_serializer_class(self): + return WriteFeature1Serializer if self.request.method == "POST" else ReadFeature1Serializer + + @extend_schema(summary="List feature 1s visible to the caller", tags=["elm"], + examples=[OpenApiExample("open feature 1", value={...})]) + def get(self, *a, **kw): + return super().get(*a, **kw) +``` + +tests/test_feature1.py diff --git a/apps.py b/apps.py new file mode 100644 index 00000000..7c517e2d --- /dev/null +++ b/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig +from django.core.checks import register + + +class ELMConfig(AppConfig): + name = "modules.elm" + label = "elm" + verbose_name = "Example Module" + + def ready(self) -> None: + # ELM_UPLOAD_ROOT is this module's setting, so it owns the check. + from core.files.checks import check_upload_root + + register(check_upload_root) +``` + +registry.py diff --git a/docs/phase2-evidence/README.md b/docs/phase2-evidence/README.md new file mode 100644 index 00000000..de451f2d --- /dev/null +++ b/docs/phase2-evidence/README.md @@ -0,0 +1,36 @@ +# ELM Phase 2 Development Evidence + +## Objective +Implement the Employee Leave Management module in the Fusion-Integrated +repository while preserving the Phase 1 branch. + +## Development environment +- Coding agent: Aider +- Model: Ollama qwen2.5-coder:3b +- Repository branch: phase2-elm-improvements +- Operating environment: Windows PowerShell + +## Current implementation +- Django app configuration: modules/elm/apps.py +- Domain entities: modules/elm/domain/entities.py +- Domain exceptions: modules/elm/domain/errors.py +- Domain tests: modules/elm/tests/test_entities.py + +## Evidence contents +- prompts.md records the main implementation prompts. +- verification-log.md records commands, test outcomes, and limitations. + +## Scope limitation +The current work covers the initial domain foundation and basic unit tests. +Leave entitlement calculations, calendar validation, overlap detection, +approval workflows, persistence, APIs, permissions, and integration remain +future implementation tasks until they are actually implemented and tested. + +This evidence must be updated as development continues. Do not describe +unimplemented functionality as complete. + +## Latest verified milestone +- Continuous leave-day counting implemented in `modules/elm/domain/policy.py`. +- The focused domain suite currently contains 12 passing tests. +- Focused Flake8 checks completed successfully. +- Broader ELM business rules and integration are still pending. diff --git a/docs/phase2-evidence/prompts.md b/docs/phase2-evidence/prompts.md new file mode 100644 index 00000000..db2438b9 --- /dev/null +++ b/docs/phase2-evidence/prompts.md @@ -0,0 +1,71 @@ +# ELM Phase 2 Prompt Log + +The following are the main implementation instructions used in the +development session. Tool responses and failed attempts are recorded in the +verification log; this file is not a verbatim export of the entire chat. + +## Prompt 1 — Domain entities + +Create modules/elm/domain/entities.py as an actual repository file. + +- Define LeaveCategory with exactly CL, RH, SCL, EL, COL, and VL. +- Define frozen dataclasses LeaveRequestDateRange and LeaveRequest. +- Reject a date range where start_date is later than end_date. +- Use Python standard-library types and keep the domain independent of Django. +- Make an actual file edit rather than returning a plan. + +## Prompt 2 — Domain exceptions + +Create modules/elm/domain/errors.py. + +Define ELMDomainError and specific exceptions: +InvalidDateRangeError, IneligibleCategoryError, InsufficientBalanceError, +OverlappingLeaveError, and InvalidWorkflowTransitionError. + +Use Python standard-library features and create the actual repository file. + +## Prompt 3 — Domain tests + +Implement focused unit tests for the ELM domain foundation. + +- Verify the six supported leave-category values. +- Test valid and reversed date ranges. +- Test LeaveRequest construction using the current API. +- Test inheritance and instantiation of the specific domain exceptions. +- Follow repository test conventions. +- Do not test business logic that has not yet been implemented. +- Do not modify unrelated modules. + +## Prompt 4 — Test correction + +After the generated test file contained duplicate classes and incorrect +imports, replace it with one clean unittest test module using imports from +modules.elm.domain.entities and modules.elm.domain.errors. + +Preserve the existing production implementation and run the focused tests. + +## Tool-use note + +Aider generated some incorrect or duplicated content during initial attempts. +The test file was subsequently cleaned up manually. OpenCode was also tried, +but its session produced unrelated task content; it was not used as evidence +of a successful ELM implementation. + +## Milestone: Continuous Leave-Day Counting + +Implemented in `modules/elm/domain/policy.py`. + +Function: +`count_continuous_leave_days(category, date_range)` + +Behavior: +- Supports EL, COL, and VL only. +- Counts start and end dates inclusively. +- Includes weekends and closed holidays. +- Rejects CL, RH, and SCL for this continuous-counting operation. +- Rejects invalid argument types. + +The implementation was written into the repository using PowerShell +after reviewing and correcting the proposed implementation. Aider's earlier +attempt contained incorrect weekday-only counting and duplicated domain +classes, so that proposal was not used as the final implementation. diff --git a/docs/phase2-evidence/verification-log.md b/docs/phase2-evidence/verification-log.md new file mode 100644 index 00000000..b975d34e --- /dev/null +++ b/docs/phase2-evidence/verification-log.md @@ -0,0 +1,110 @@ +# ELM Phase 2 Verification Log + +## Initial domain tests +Command: +python -m unittest modules.elm.tests.test_entities -v + +Recorded result: +6 tests passed. + +Covered: +- Exact leave-category values +- Valid date range +- Reversed date range rejection +- LeaveRequest construction +- Domain exception inheritance +- Domain exception instantiation + +## Lint +A focused Flake8 command was run against the ELM domain and test files. + +Recorded result: +Exit code 0 with no lint output for the selected checks. + +## Diff check +Command: +git diff --cached --check + +Recorded result: +No whitespace errors were reported. Git previously warned that LF line +endings may be converted to CRLF on Windows; this was a line-ending warning. + +## Test environment note +The command pytest modules/elm/tests/ could not run because pytest was not +available as a recognized PowerShell command. The tests were run successfully +using Python's built-in unittest runner instead. + +## Current limitations +These results verify only the initial domain foundation. They do not verify +entitlements, CL half-day calculations, RH calendar rules, eligibility, +overlap detection, approval workflows, database persistence, API behavior, +permissions, or frontend integration. + +Update this file with new commands and their actual outcomes after every +subsequent implementation milestone. + +## Continuous leave-day counting milestone + +Command: +`python -m unittest modules.elm.tests.test_entities -v` + +Actual result: +12 tests passed in 0.003 seconds. + +Additional verification: +- Focused Flake8 command completed with exit code 0. +- `git diff --check` reported no whitespace errors. +- Git emitted a Windows LF/CRLF line-ending warning for the test file. + This is a warning, not a test failure. + +Additional test coverage: +- EL, COL, and VL count inclusive calendar days. +- A one-day interval counts as one day. +- Friday through Monday counts as four calendar days. +- CL, RH, and SCL are rejected by this function. +- Incorrect argument types are rejected. +- `datetime` boundaries are rejected by the date-only policy. + +Limitations: +This function calculates continuous calendar days only. It does not yet +implement leave balances, entitlement enforcement, employee eligibility, +RH calendar validation, overlap detection, or approval workflows. + +## Milestone: Sufficient-Balance Validation + +Implemented in `modules/elm/domain/policy.py`. + +Function: +`validate_sufficient_balance(available_balance, requested_days)` + +Verified behavior: +- Accepts non-negative integer and Decimal quantities. +- Supports fractional leave values such as 0.5. +- Rejects negative values and invalid argument types. +- Rejects non-finite Decimal values. +- Raises InsufficientBalanceError when the requested amount exceeds + the available balance. + +Verification command: +`python -m unittest modules.elm.tests.test_entities modules.elm.tests.test_policy -v` + +Recorded result: +19 tests passed. + +Focused Flake8 checks completed with exit code 0. + +Limitations: +This validates a supplied balance and requested quantity. It does not +calculate annual entitlement, determine employee eligibility, or deduct +leave from a database balance. + +## Annual leave limits — 2026-10-17 + +- Added annual-limit policy handling for CL, RH, SCL, EL, VL, and COL. +- EL is limited to 30 days for non-faculty employees; the faculty EL limit remains unspecified. +- VL is limited to eligible faculty, with a limit of 60 days. +- COL requires explicit eligibility; the configured limit is 20 days. +- SCL's 15-day value represents a maximum, not an automatic grant. +- Verification: 26 unit tests passed across the entity, balance-policy, and entitlement-policy test modules. +- Focused Flake8 check for selected syntax and undefined-name errors returned exit code 0. +- Scope limitation: these results verify the tested domain helpers, not the complete ELM application or its approval workflows. diff --git a/modules/elm/apps.py b/modules/elm/apps.py new file mode 100644 index 00000000..209c0805 --- /dev/null +++ b/modules/elm/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.core.checks import register + + +class ElmConfig(AppConfig): + name = "modules.elm" + label = "elm" diff --git a/modules/elm/domain/entities.py b/modules/elm/domain/entities.py new file mode 100644 index 00000000..9d6115e7 --- /dev/null +++ b/modules/elm/domain/entities.py @@ -0,0 +1,25 @@ +from enum import Enum +from dataclasses import dataclass +from datetime import date + +class LeaveCategory(Enum): + CL = "CL" + RH = "RH" + SCL = "SCL" + EL = "EL" + COL = "COL" + VL = "VL" + +@dataclass(frozen=True) +class LeaveRequestDateRange: + start_date: date + end_date: date + + def __post_init__(self): + if self.start_date > self.end_date: + raise ValueError("start_date must not be later than end_date") + +@dataclass(frozen=True) +class LeaveRequest: + category: LeaveCategory + date_range: LeaveRequestDateRange diff --git a/modules/elm/domain/errors.py b/modules/elm/domain/errors.py new file mode 100644 index 00000000..761a248b --- /dev/null +++ b/modules/elm/domain/errors.py @@ -0,0 +1,39 @@ +from datetime import date +from decimal import Decimal + +class ELMDomainError(Exception): + """Base class for all ELM domain errors.""" + pass + +class InvalidDateRangeError(ELMDomainError): + """Error raised when the date range is invalid.""" + def __init__(self, start_date: date, end_date: date): + self.start_date = start_date + self.end_date = end_date + super().__init__(f"Invalid date range: {start_date} to {end_date}") + +class IneligibleCategoryError(ELMDomainError): + """Error raised when the leave category is ineligible.""" + def __init__(self, category: str): + self.category = category + super().__init__(f"Category {category} is ineligible for leave.") + +class InsufficientBalanceError(ELMDomainError): + """Error raised when the leave balance is insufficient.""" + def __init__(self, balance: int | Decimal, required: int | Decimal): + self.balance = balance + self.required = required + super().__init__(f"Insufficient leave balance: {balance} available, {required} required.") + +class OverlappingLeaveError(ELMDomainError): + """Error raised when the leave request overlaps with an existing leave request.""" + def __init__(self, request: object): + self.request = request + super().__init__(f"Leave request overlaps with existing request: {request}") + +class InvalidWorkflowTransitionError(ELMDomainError): + """Error raised when an invalid workflow transition is attempted.""" + def __init__(self, current_state: str, target_state: str): + self.current_state = current_state + self.target_state = target_state + super().__init__(f"Invalid workflow transition: {current_state} -> {target_state}") diff --git a/modules/elm/domain/policy.py b/modules/elm/domain/policy.py new file mode 100644 index 00000000..e5d25e12 --- /dev/null +++ b/modules/elm/domain/policy.py @@ -0,0 +1,139 @@ +from datetime import date +from decimal import Decimal + +from modules.elm.domain.entities import ( + LeaveCategory, + LeaveRequestDateRange, +) + + +_CONTINUOUS_CATEGORIES = ( + LeaveCategory.EL, + LeaveCategory.COL, + LeaveCategory.VL, +) + + +def count_continuous_leave_days( + category: LeaveCategory, + date_range: LeaveRequestDateRange, +) -> int: + """Count inclusive calendar days for EL, COL, or VL.""" + if not isinstance(category, LeaveCategory): + raise TypeError("category must be a LeaveCategory") + + if not isinstance(date_range, LeaveRequestDateRange): + raise TypeError("date_range must be a LeaveRequestDateRange") + + if type(date_range.start_date) is not date or type(date_range.end_date) is not date: + raise TypeError("date range boundaries must be dates") + + if category not in _CONTINUOUS_CATEGORIES: + raise ValueError( + f"{category.value} does not use continuous leave counting" + ) + + return (date_range.end_date - date_range.start_date).days + 1 + + +def validate_sufficient_balance( + available_balance: int | Decimal, + requested_days: int | Decimal, +) -> None: + """Raise an error when requested leave exceeds the available balance.""" + values = ( + ("available_balance", available_balance), + ("requested_days", requested_days), + ) + + for name, value in values: + if isinstance(value, bool) or not isinstance(value, (int, Decimal)): + raise TypeError(f"{name} must be an integer or Decimal") + + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError(f"{name} must be finite") + + if value < 0: + raise ValueError(f"{name} cannot be negative") + + available = Decimal(available_balance) + requested = Decimal(requested_days) + + if requested > available: + from modules.elm.domain.errors import InsufficientBalanceError + raise InsufficientBalanceError(available, requested) + + return None + + + +def get_annual_limit( + category: LeaveCategory, + *, + is_faculty: bool | None = None, + col_eligible: bool | None = None, +) -> Decimal: + """Return the documented annual limit for a leave category. + + SCL's value is a maximum, not an automatic grant. + COL requires explicit eligibility. + """ + if not isinstance(category, LeaveCategory): + raise TypeError("category must be a LeaveCategory") + + fixed_limits = { + LeaveCategory.CL: Decimal("8"), + LeaveCategory.RH: Decimal("2"), + LeaveCategory.SCL: Decimal("15"), + } + if category in fixed_limits: + return fixed_limits[category] + + if category in (LeaveCategory.EL, LeaveCategory.VL): + if not isinstance(is_faculty, bool): + raise TypeError("is_faculty must be a bool for EL/VL") + + if category is LeaveCategory.EL: + if is_faculty: + raise ValueError("Faculty EL limit is unspecified") + return Decimal("30") + + if not is_faculty: + raise ValueError("VL is applicable to faculty only") + return Decimal("60") + + if category is LeaveCategory.COL: + if not isinstance(col_eligible, bool): + raise TypeError("col_eligible must be a bool for COL") + if not col_eligible: + raise ValueError("Employee is not eligible for COL") + return Decimal("20") + + raise ValueError(f"No annual limit defined for {category!r}") + + +def validate_category_eligibility( + category: LeaveCategory, + *, + is_faculty: bool | None = None, + col_eligible: bool | None = None, +) -> None: + """Raise IneligibleCategoryError when the employee cannot use a category.""" + from modules.elm.domain.errors import IneligibleCategoryError + + if not isinstance(category, LeaveCategory): + raise TypeError("category must be a LeaveCategory") + + if category is LeaveCategory.VL: + if not isinstance(is_faculty, bool): + raise TypeError("is_faculty must be a bool for VL") + if not is_faculty: + raise IneligibleCategoryError(category.value) + + elif category is LeaveCategory.COL: + if not isinstance(col_eligible, bool): + raise TypeError("col_eligible must be a bool for COL") + if not col_eligible: + raise IneligibleCategoryError(category.value) + + return None diff --git a/modules/elm/tests/__init__.py b/modules/elm/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/tests/test_eligibility.py b/modules/elm/tests/test_eligibility.py new file mode 100644 index 00000000..0dcb32cf --- /dev/null +++ b/modules/elm/tests/test_eligibility.py @@ -0,0 +1,61 @@ +import unittest + +from modules.elm.domain.entities import LeaveCategory +from modules.elm.domain.errors import IneligibleCategoryError +from modules.elm.domain.policy import validate_category_eligibility + + +class TestCategoryEligibility(unittest.TestCase): + def test_faculty_can_use_vl(self): + self.assertIsNone( + validate_category_eligibility( + LeaveCategory.VL, is_faculty=True + ) + ) + + def test_nonfaculty_cannot_use_vl(self): + with self.assertRaises(IneligibleCategoryError): + validate_category_eligibility( + LeaveCategory.VL, is_faculty=False + ) + + def test_vl_requires_explicit_faculty_status(self): + with self.assertRaises(TypeError): + validate_category_eligibility(LeaveCategory.VL) + + def test_eligible_employee_can_use_col(self): + self.assertIsNone( + validate_category_eligibility( + LeaveCategory.COL, col_eligible=True + ) + ) + + def test_ineligible_employee_cannot_use_col(self): + with self.assertRaises(IneligibleCategoryError): + validate_category_eligibility( + LeaveCategory.COL, col_eligible=False + ) + + def test_col_requires_explicit_eligibility(self): + with self.assertRaises(TypeError): + validate_category_eligibility(LeaveCategory.COL) + + def test_other_categories_need_no_extra_eligibility_argument(self): + for category in ( + LeaveCategory.CL, + LeaveCategory.RH, + LeaveCategory.SCL, + LeaveCategory.EL, + ): + with self.subTest(category=category): + self.assertIsNone( + validate_category_eligibility(category) + ) + + def test_invalid_category_type_is_rejected(self): + with self.assertRaises(TypeError): + validate_category_eligibility("VL") + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/elm/tests/test_entities.py b/modules/elm/tests/test_entities.py new file mode 100644 index 00000000..d48af1b7 --- /dev/null +++ b/modules/elm/tests/test_entities.py @@ -0,0 +1,140 @@ +import unittest +from datetime import date + +from modules.elm.domain.entities import ( + LeaveCategory, + LeaveRequest, + LeaveRequestDateRange, +) +from modules.elm.domain.errors import ( + ELMDomainError, + InvalidDateRangeError, + IneligibleCategoryError, + InsufficientBalanceError, + OverlappingLeaveError, + InvalidWorkflowTransitionError, +) +from modules.elm.domain.policy import count_continuous_leave_days + + +class TestLeaveCategory(unittest.TestCase): + def test_exact_supported_categories(self): + self.assertEqual( + [(item.name, item.value) for item in LeaveCategory], + [ + ("CL", "CL"), + ("RH", "RH"), + ("SCL", "SCL"), + ("EL", "EL"), + ("COL", "COL"), + ("VL", "VL"), + ], + ) + + +class TestLeaveRequestDateRange(unittest.TestCase): + def test_valid_date_range(self): + start = date(2026, 10, 1) + end = date(2026, 10, 10) + date_range = LeaveRequestDateRange(start, end) + self.assertEqual(date_range.start_date, start) + self.assertEqual(date_range.end_date, end) + + def test_reversed_date_range_is_rejected(self): + with self.assertRaises(ValueError): + LeaveRequestDateRange(date(2026, 10, 10), date(2026, 10, 1)) + + +class TestLeaveRequest(unittest.TestCase): + def test_leave_request_creation(self): + date_range = LeaveRequestDateRange( + date(2026, 10, 1), date(2026, 10, 10) + ) + request = LeaveRequest(LeaveCategory.CL, date_range) + self.assertEqual(request.category, LeaveCategory.CL) + self.assertEqual(request.date_range, date_range) + + +class TestELMDomainErrors(unittest.TestCase): + def test_specific_errors_inherit_from_base(self): + for exception_type in ( + InvalidDateRangeError, + IneligibleCategoryError, + InsufficientBalanceError, + OverlappingLeaveError, + InvalidWorkflowTransitionError, + ): + with self.subTest(exception=exception_type.__name__): + self.assertTrue(issubclass(exception_type, ELMDomainError)) + + def test_specific_errors_can_be_instantiated(self): + instances = [ + InvalidDateRangeError(date(2026, 10, 10), date(2026, 10, 1)), + IneligibleCategoryError("CL"), + InsufficientBalanceError(2, 5), + OverlappingLeaveError(object()), + InvalidWorkflowTransitionError("pending", "approved"), + ] + for exception in instances: + with self.subTest(exception=type(exception).__name__): + self.assertIsInstance(exception, ELMDomainError) + + +class TestCountContinuousLeaveDays(unittest.TestCase): + def test_datetime_boundaries_are_rejected(self): + from datetime import datetime + + date_range = LeaveRequestDateRange( + datetime(2026, 10, 1, 9, 0), + datetime(2026, 10, 2, 9, 0), + ) + + with self.assertRaises(TypeError): + count_continuous_leave_days(LeaveCategory.EL, date_range) + + def test_supported_categories_count_all_calendar_days(self): + date_range = LeaveRequestDateRange( + date(2026, 10, 1), date(2026, 10, 10) + ) + for category in (LeaveCategory.EL, LeaveCategory.COL, LeaveCategory.VL): + with self.subTest(category=category.value): + self.assertEqual(count_continuous_leave_days(category, date_range), 10) + + def test_single_day_interval(self): + date_range = LeaveRequestDateRange( + date(2026, 10, 3), date(2026, 10, 3) + ) + self.assertEqual( + count_continuous_leave_days(LeaveCategory.EL, date_range), 1 + ) + + def test_weekend_is_included(self): + # Friday through Monday includes Saturday and Sunday. + date_range = LeaveRequestDateRange( + date(2026, 10, 2), date(2026, 10, 5) + ) + self.assertEqual( + count_continuous_leave_days(LeaveCategory.EL, date_range), 4 + ) + + def test_cl_rh_and_scl_are_rejected(self): + date_range = LeaveRequestDateRange( + date(2026, 10, 1), date(2026, 10, 10) + ) + for category in (LeaveCategory.CL, LeaveCategory.RH, LeaveCategory.SCL): + with self.subTest(category=category.value): + with self.assertRaises(ValueError): + count_continuous_leave_days(category, date_range) + + def test_invalid_argument_types_are_rejected(self): + date_range = LeaveRequestDateRange( + date(2026, 10, 1), date(2026, 10, 10) + ) + with self.assertRaises(TypeError): + count_continuous_leave_days("EL", date_range) + with self.assertRaises(TypeError): + count_continuous_leave_days(LeaveCategory.EL, object()) + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/elm/tests/test_entitlements.py b/modules/elm/tests/test_entitlements.py new file mode 100644 index 00000000..12ad9b1c --- /dev/null +++ b/modules/elm/tests/test_entitlements.py @@ -0,0 +1,59 @@ +import unittest +from decimal import Decimal + +from modules.elm.domain.entities import LeaveCategory +from modules.elm.domain.policy import get_annual_limit + + +class TestAnnualLeaveLimits(unittest.TestCase): + def test_fixed_limits(self): + expected = { + LeaveCategory.CL: Decimal("8"), + LeaveCategory.RH: Decimal("2"), + LeaveCategory.SCL: Decimal("15"), + } + for category, limit in expected.items(): + with self.subTest(category=category.value): + self.assertEqual(get_annual_limit(category), limit) + + def test_nonfaculty_el_limit(self): + self.assertEqual( + get_annual_limit(LeaveCategory.EL, is_faculty=False), + Decimal("30"), + ) + + def test_faculty_el_limit_is_not_invented(self): + with self.assertRaises(ValueError): + get_annual_limit(LeaveCategory.EL, is_faculty=True) + + def test_faculty_vl_limit(self): + self.assertEqual( + get_annual_limit(LeaveCategory.VL, is_faculty=True), + Decimal("60"), + ) + + def test_nonfaculty_vl_is_rejected(self): + with self.assertRaises(ValueError): + get_annual_limit(LeaveCategory.VL, is_faculty=False) + + def test_col_requires_eligibility(self): + self.assertEqual( + get_annual_limit(LeaveCategory.COL, col_eligible=True), + Decimal("20"), + ) + with self.assertRaises(TypeError): + get_annual_limit(LeaveCategory.COL) + with self.assertRaises(ValueError): + get_annual_limit(LeaveCategory.COL, col_eligible=False) + + def test_invalid_argument_types(self): + with self.assertRaises(TypeError): + get_annual_limit("CL") + with self.assertRaises(TypeError): + get_annual_limit(LeaveCategory.EL, is_faculty=1) + with self.assertRaises(TypeError): + get_annual_limit(LeaveCategory.COL, col_eligible=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/elm/tests/test_policy.py b/modules/elm/tests/test_policy.py new file mode 100644 index 00000000..bd9d1b74 --- /dev/null +++ b/modules/elm/tests/test_policy.py @@ -0,0 +1,58 @@ +import unittest +from decimal import Decimal + +from modules.elm.domain.errors import ( + ELMDomainError, + InsufficientBalanceError, +) +from modules.elm.domain.policy import validate_sufficient_balance + + +class TestValidateSufficientBalance(unittest.TestCase): + def test_sufficient_integer_balance(self): + self.assertIsNone(validate_sufficient_balance(10, 8)) + + def test_exact_balance_is_sufficient(self): + self.assertIsNone(validate_sufficient_balance(5, 5)) + + def test_half_day_quantities(self): + self.assertIsNone( + validate_sufficient_balance(Decimal("1.5"), Decimal("0.5")) + ) + + def test_insufficient_balance_raises_domain_error(self): + with self.assertRaises(InsufficientBalanceError) as caught: + validate_sufficient_balance(Decimal("1.5"), Decimal("2.0")) + + self.assertIsInstance(caught.exception, ELMDomainError) + self.assertEqual(caught.exception.balance, Decimal("1.5")) + self.assertEqual(caught.exception.required, Decimal("2.0")) + + def test_negative_values_are_rejected(self): + with self.assertRaises(ValueError): + validate_sufficient_balance(-1, 0) + + with self.assertRaises(ValueError): + validate_sufficient_balance(1, Decimal("-0.5")) + + def test_invalid_types_are_rejected(self): + for value in (True, 1.5, "2", None): + with self.subTest(value=value): + with self.assertRaises(TypeError): + validate_sufficient_balance(value, 1) + + for value in (False, 0.5, "1", None): + with self.subTest(value=value): + with self.assertRaises(TypeError): + validate_sufficient_balance(1, value) + + def test_non_finite_decimal_is_rejected(self): + with self.assertRaises(ValueError): + validate_sufficient_balance(Decimal("NaN"), 1) + + with self.assertRaises(ValueError): + validate_sufficient_balance(1, Decimal("Infinity")) + + +if __name__ == "__main__": + unittest.main()