feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped

- 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:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -0,0 +1,310 @@
from __future__ import annotations
from collections.abc import Callable
import pytest
from fastapi.testclient import TestClient
from services.unit_of_work import UnitOfWork
def _create_project(client: TestClient, name: str) -> int:
response = client.post(
"/projects",
json={
"name": name,
"location": "Nevada",
"operation_type": "open_pit",
"description": "Project for opex testing",
},
)
assert response.status_code == 201
return response.json()["id"]
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
response = client.post(
f"/projects/{project_id}/scenarios",
json={
"name": name,
"description": "Processing opex scenario",
"status": "draft",
"currency": "usd",
"primary_resource": "diesel",
},
)
assert response.status_code == 201
return response.json()["id"]
def test_opex_calculation_html_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Opex HTML Project")
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
form_page = client.get(
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}"
)
assert form_page.status_code == 200
assert "Opex Planner" in form_page.text
response = client.post(
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
data={
"components[0][name]": "Power",
"components[0][category]": "energy",
"components[0][unit_cost]": "1000",
"components[0][quantity]": "1",
"components[0][frequency]": "monthly",
"components[0][currency]": "USD",
"components[0][period_start]": "1",
"components[0][period_end]": "3",
"components[1][name]": "Maintenance",
"components[1][category]": "maintenance",
"components[1][unit_cost]": "2500",
"components[1][quantity]": "1",
"components[1][frequency]": "quarterly",
"components[1][currency]": "USD",
"components[1][period_start]": "1",
"components[1][period_end]": "2",
"parameters[currency_code]": "USD",
"parameters[escalation_pct]": "5",
"parameters[discount_rate_pct]": "3",
"parameters[evaluation_horizon_years]": "3",
"parameters[apply_escalation]": "1",
"options[persist]": "1",
"options[snapshot_notes]": "Opex HTML flow",
},
)
assert response.status_code == 200
assert "Opex calculation completed successfully." in response.text
assert "Opex Summary" in response.text
assert "$22,000.00" in response.text or "22,000" in response.text
with unit_of_work_factory() as uow:
assert uow.project_opex is not None
assert uow.scenario_opex is not None
project_snapshots = uow.project_opex.list_for_project(
project_id)
scenario_snapshots = uow.scenario_opex.list_for_scenario(
scenario_id)
assert len(project_snapshots) == 1
assert len(scenario_snapshots) == 1
project_snapshot = project_snapshots[0]
scenario_snapshot = scenario_snapshots[0]
assert project_snapshot.overall_annual is not None
assert float(
project_snapshot.overall_annual) == pytest.approx(22_000.0)
assert project_snapshot.escalated_total is not None
assert float(
project_snapshot.escalated_total) == pytest.approx(58_330.0)
assert project_snapshot.apply_escalation is True
assert project_snapshot.component_count == 2
assert project_snapshot.currency_code == "USD"
assert scenario_snapshot.overall_annual is not None
assert float(
scenario_snapshot.overall_annual) == pytest.approx(22_000.0)
assert scenario_snapshot.escalated_total is not None
assert float(
scenario_snapshot.escalated_total) == pytest.approx(58_330.0)
assert scenario_snapshot.apply_escalation is True
assert scenario_snapshot.component_count == 2
assert scenario_snapshot.currency_code == "USD"
def test_opex_calculation_json_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Opex JSON Project")
scenario_id = _create_scenario(client, project_id, "Opex JSON Scenario")
payload = {
"components": [
{
"name": "Reagents",
"category": "materials",
"unit_cost": 400,
"quantity": 10,
"frequency": "monthly",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
{
"name": "Labor",
"category": "labor",
"unit_cost": 1500,
"quantity": 4,
"frequency": "weekly",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
{
"name": "Maintenance",
"category": "maintenance",
"unit_cost": 12000,
"quantity": 1,
"frequency": "annually",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
],
"parameters": {
"currency_code": "USD",
"escalation_pct": 4,
"discount_rate_pct": 2,
"evaluation_horizon_years": 3,
"apply_escalation": True,
},
"options": {"persist": True, "snapshot_notes": "Processing opex JSON flow"},
}
response = client.post(
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload,
)
assert response.status_code == 200
data = response.json()
assert data["currency"] == "USD"
expected_overall = 372_000.0
escalation_factor = 1 + (payload["parameters"]["escalation_pct"] / 100.0)
expected_timeline = [
expected_overall * (escalation_factor ** i) for i in range(payload["parameters"]["evaluation_horizon_years"])
]
expected_escalated_total = sum(expected_timeline)
expected_average = expected_escalated_total / len(expected_timeline)
assert data["totals"]["overall_annual"] == pytest.approx(expected_overall)
assert data["totals"]["escalated_total"] == pytest.approx(
expected_escalated_total)
assert data["totals"]["escalation_pct"] == pytest.approx(4.0)
by_category = {entry["category"] : entry for entry in data["totals"]["by_category"]}
assert by_category["materials"]["annual_cost"] == pytest.approx(48_000.0)
assert by_category["labor"]["annual_cost"] == pytest.approx(312_000.0)
assert by_category["maintenance"]["annual_cost"] == pytest.approx(12_000.0)
assert len(data["timeline"]) == 3
for index, entry in enumerate(data["timeline"], start=0):
assert entry["period"] == index + 1
assert entry["base_cost"] == pytest.approx(expected_overall)
assert entry["escalated_cost"] == pytest.approx(
expected_timeline[index])
assert data["metrics"]["annual_average"] == pytest.approx(expected_average)
with unit_of_work_factory() as uow:
assert uow.project_opex is not None
assert uow.scenario_opex is not None
project_snapshot = uow.project_opex.latest_for_project(
project_id)
scenario_snapshot = uow.scenario_opex.latest_for_scenario(
scenario_id)
assert project_snapshot is not None
assert scenario_snapshot is not None
assert project_snapshot.overall_annual is not None
assert float(project_snapshot.overall_annual) == pytest.approx(
expected_overall)
assert project_snapshot.escalated_total is not None
assert float(project_snapshot.escalated_total) == pytest.approx(
expected_escalated_total)
assert project_snapshot.apply_escalation is True
assert scenario_snapshot.annual_average is not None
assert float(scenario_snapshot.annual_average) == pytest.approx(
expected_average)
assert scenario_snapshot.apply_escalation is True
@pytest.mark.parametrize("content_type", ["form", "json"])
def test_opex_calculation_currency_mismatch(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
content_type: str,
) -> None:
project_id = _create_project(
client, f"Opex {content_type.title()} Error Project")
scenario_id = _create_scenario(
client, project_id, f"Opex {content_type.title()} Error Scenario")
if content_type == "json":
payload = {
"components": [
{
"name": "Power",
"category": "energy",
"unit_cost": 500,
"quantity": 1,
"frequency": "monthly",
"currency": "USD",
"period_start": 1,
"period_end": 2,
}
],
"parameters": {"currency_code": "CAD"},
"options": {"persist": True},
}
response = client.post(
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload,
)
assert response.status_code == 422
body = response.json()
assert "Component currency does not match" in body.get("message", "")
assert any(
"components[0].currency" in entry for entry in body.get("errors", []))
else:
response = client.post(
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
data={
"components[0][name]": "Power",
"components[0][category]": "energy",
"components[0][unit_cost]": "500",
"components[0][quantity]": "1",
"components[0][frequency]": "monthly",
"components[0][currency]": "USD",
"components[0][period_start]": "1",
"components[0][period_end]": "2",
"parameters[currency_code]": "CAD",
"options[persist]": "1",
},
)
assert response.status_code == 422
assert hasattr(response, "context")
context = getattr(response, "context", {}) or {}
combined_errors = [
str(entry)
for entry in (
(context.get("errors") or [])
+ (context.get("component_errors") or [])
)
]
assert any(
"components[0].currency" in entry for entry in combined_errors)
with unit_of_work_factory() as uow:
assert uow.project_opex is not None
assert uow.scenario_opex is not None
project_snapshots = uow.project_opex.list_for_project(
project_id)
scenario_snapshots = uow.scenario_opex.list_for_scenario(
scenario_id)
assert project_snapshots == []
assert scenario_snapshots == []