feat: add scenarios list page with metrics and quick actions
- Introduced a new template for listing scenarios associated with a project. - Added metrics for total, active, draft, and archived scenarios. - Implemented quick actions for creating new scenarios and reviewing project overview. - Enhanced navigation with breadcrumbs for better user experience. refactor: update Opex and Profitability templates for consistency - Changed titles and button labels for clarity in Opex and Profitability templates. - Updated form IDs and action URLs for better alignment with new naming conventions. - Improved navigation links to include scenario and project overviews. test: add integration tests for Opex calculations - Created new tests for Opex calculation HTML and JSON flows. - Validated successful calculations and ensured correct data persistence. - Implemented tests for currency mismatch and unsupported frequency scenarios. test: enhance project and scenario route tests - Added tests to verify scenario list rendering and calculator shortcuts. - Ensured scenario detail pages link back to the portfolio correctly. - Validated project detail pages show associated scenarios accurately.
This commit is contained in:
159
tests/services/test_calculations_opex.py
Normal file
159
tests/services/test_calculations_opex.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
OpexCalculationRequest,
|
||||
OpexComponentInput,
|
||||
OpexOptions,
|
||||
OpexParameters,
|
||||
)
|
||||
from services.calculations import calculate_opex
|
||||
from services.exceptions import OpexValidationError
|
||||
|
||||
|
||||
def _component(**overrides) -> OpexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
"category": "energy",
|
||||
"unit_cost": 1000.0,
|
||||
"quantity": 1.0,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 1,
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OpexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_opex_success():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(
|
||||
name="Power",
|
||||
category="energy",
|
||||
unit_cost=1000.0,
|
||||
quantity=1,
|
||||
frequency="monthly",
|
||||
period_start=1,
|
||||
period_end=3,
|
||||
),
|
||||
_component(
|
||||
name="Maintenance",
|
||||
category="maintenance",
|
||||
unit_cost=2500.0,
|
||||
quantity=1,
|
||||
frequency="quarterly",
|
||||
period_start=1,
|
||||
period_end=2,
|
||||
),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=5,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=True,
|
||||
),
|
||||
options=OpexOptions(persist=True, snapshot_notes=None),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert result.currency == "USD"
|
||||
assert result.options.persist is True
|
||||
|
||||
assert result.totals.overall_annual == pytest.approx(22_000.0)
|
||||
assert result.totals.escalated_total == pytest.approx(58_330.0, rel=1e-4)
|
||||
assert result.totals.escalation_pct == pytest.approx(5.0)
|
||||
|
||||
categories = {entry.category: entry for entry in result.totals.by_category}
|
||||
assert categories["energy"].annual_cost == pytest.approx(12_000.0)
|
||||
assert categories["maintenance"].annual_cost == pytest.approx(10_000.0)
|
||||
|
||||
assert len(result.timeline) == 3
|
||||
timeline = {entry.period: entry for entry in result.timeline}
|
||||
assert timeline[1].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[3].base_cost == pytest.approx(12_000.0)
|
||||
assert timeline[1].escalated_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].escalated_cost == pytest.approx(23_100.0, rel=1e-4)
|
||||
assert timeline[3].escalated_cost == pytest.approx(13_230.0, rel=1e-4)
|
||||
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
19_443.3333, rel=1e-4)
|
||||
|
||||
assert len(result.components) == 2
|
||||
assert result.components[0].frequency == "monthly"
|
||||
assert result.components[1].frequency == "quarterly"
|
||||
|
||||
|
||||
def test_calculate_opex_currency_mismatch():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(currency="USD")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="CAD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=10,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_unsupported_frequency():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(frequency="biweekly")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Unsupported frequency" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_requires_components():
|
||||
request = OpexCalculationRequest(components=[])
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "At least one opex component" in exc.value.message
|
||||
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_extends_evaluation_horizon():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(period_start=1, period_end=4),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
discount_rate_pct=0,
|
||||
escalation_pct=0,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert len(result.timeline) == 4
|
||||
assert result.timeline[-1].period == 4
|
||||
assert all(entry.escalated_cost is None for entry in result.timeline)
|
||||
assert result.timeline[-1].base_cost == pytest.approx(12_000.0)
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
12_000.0, rel=1e-4)
|
||||
Reference in New Issue
Block a user