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

@@ -1,5 +1,8 @@
from __future__ import annotations
import html
from fastapi import status
from fastapi.testclient import TestClient
from models import MiningOperationType, ResourceType, ScenarioStatus
@@ -17,6 +20,32 @@ def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
return response.json()
def _create_scenario(
client: TestClient,
project_id: int,
*,
name: str = "Scenario A",
status: ScenarioStatus = ScenarioStatus.DRAFT,
currency: str | None = "USD",
primary_resource: ResourceType | None = ResourceType.DIESEL,
) -> dict:
payload = {
"name": name,
"status": status.value,
}
if currency:
payload["currency"] = currency
if primary_resource:
payload["primary_resource"] = primary_resource.value
response = client.post(
f"/projects/{project_id}/scenarios",
json=payload,
)
assert response.status_code == 201, response.text
return response.json()
def test_project_crud_cycle(client: TestClient) -> None:
project = _create_project(client)
project_id = project["id"]
@@ -29,10 +58,13 @@ def test_project_crud_cycle(client: TestClient) -> None:
project_ids = {item["id"] for item in list_response.json()}
assert project_id in project_ids
update_payload = {"description": "Updated project description", "location": "Peru"}
update_response = client.put(f"/projects/{project_id}", json=update_payload)
update_payload = {
"description": "Updated project description", "location": "Peru"}
update_response = client.put(
f"/projects/{project_id}", json=update_payload)
assert update_response.status_code == 200
assert update_response.json()["description"] == "Updated project description"
assert update_response.json(
)["description"] == "Updated project description"
assert update_response.json()["location"] == "Peru"
delete_response = client.delete(f"/projects/{project_id}")
@@ -97,8 +129,10 @@ def test_scenario_crud_cycle(client: TestClient) -> None:
listed_ids = {item["id"] for item in list_response.json()}
assert scenario_id in listed_ids
update_payload = {"description": "Revised assumptions", "status": ScenarioStatus.ACTIVE.value}
update_response = client.put(f"/scenarios/{scenario_id}", json=update_payload)
update_payload = {"description": "Revised assumptions",
"status": ScenarioStatus.ACTIVE.value}
update_response = client.put(
f"/scenarios/{scenario_id}", json=update_payload)
assert update_response.status_code == 200
updated = update_response.json()
assert updated["description"] == "Revised assumptions"
@@ -125,10 +159,12 @@ def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
project_id = project["id"]
payload = {"name": "Duplicate Scenario"}
first_response = client.post(f"/projects/{project_id}/scenarios", json=payload)
first_response = client.post(
f"/projects/{project_id}/scenarios", json=payload)
assert first_response.status_code == 201
conflict_response = client.post(f"/projects/{project_id}/scenarios", json=payload)
conflict_response = client.post(
f"/projects/{project_id}/scenarios", json=payload)
assert conflict_response.status_code == 409
assert "constraints" in conflict_response.json()["detail"].lower()
@@ -150,3 +186,152 @@ def test_create_scenario_invalid_currency_returns_422(client: TestClient) -> Non
def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None:
response = client.get("/projects/424242/scenarios")
assert response.status_code == 404
def test_project_detail_page_renders_scenario_list(client: TestClient) -> None:
project = _create_project(client, name="UI Detail Project")
project_id = project["id"]
_create_scenario(
client,
project_id,
name="Scenario UI",
status=ScenarioStatus.ACTIVE,
currency="USD",
)
response = client.get(f"/projects/{project_id}/view")
assert response.status_code == 200
body = response.text
assert "scenario-list" in body
assert "status-pill--active" in body
def test_scenario_list_page_shows_calculator_shortcuts(client: TestClient) -> None:
project = _create_project(client, name="Portfolio Project")
project_id = project["id"]
scenario = _create_scenario(
client,
project_id,
name="Portfolio Scenario",
status=ScenarioStatus.ACTIVE,
currency="USD",
)
response = client.get(f"/projects/{project_id}/scenarios/ui")
assert response.status_code == 200
body = response.text
unescaped = html.unescape(body)
assert "Scenario Portfolio" in body
assert project["name"] in body
assert scenario["name"] in body
assert f"projects/{project_id}/view" in unescaped
assert f"scenarios/{scenario['id']}/view" in unescaped
expected_calc_fragment = (
f"calculations/projects/{project_id}/scenarios/{scenario['id']}/profitability"
)
assert expected_calc_fragment in unescaped
def test_scenario_detail_page_links_back_to_portfolio(client: TestClient) -> None:
project = _create_project(client, name="Detail Project")
scenario = _create_scenario(
client,
project["id"],
name="Detail Scenario",
status=ScenarioStatus.ACTIVE,
currency="USD",
primary_resource=ResourceType.ELECTRICITY,
)
response = client.get(f"/scenarios/{scenario['id']}/view")
assert response.status_code == 200
body = response.text
unescaped = html.unescape(body)
assert project["name"] in body
assert scenario["name"] in body
assert "Scenario Overview" in body
assert f"projects/{project['id']}/scenarios/ui" in unescaped
assert (
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
in unescaped
)
def test_scenario_form_includes_project_context_guidance(client: TestClient) -> None:
project = _create_project(client, name="Form Project")
response = client.get(f"/projects/{project['id']}/scenarios/new")
assert response.status_code == 200
body = response.text
assert "Project Context" in body
assert project["name"] in body
assert "Status Guidance" in body
assert "Baseline Reminder" in body
assert "Defaults to" in body
def test_calculator_headers_surface_scenario_navigation(client: TestClient) -> None:
project = _create_project(client, name="Calc Project")
scenario = _create_scenario(
client,
project["id"],
name="Calc Scenario",
status=ScenarioStatus.DRAFT,
currency="USD",
)
profitability = client.get(
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
)
assert profitability.status_code == 200
profitability_body = html.unescape(profitability.text)
assert f"scenarios/{scenario['id']}/view" in profitability_body
assert f"projects/{project['id']}/scenarios/ui" in profitability_body
assert (
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
in profitability_body
)
capex = client.get(
f"/calculations/capex?project_id={project['id']}&scenario_id={scenario['id']}"
)
assert capex.status_code == 200
capex_body = html.unescape(capex.text)
assert f"scenarios/{scenario['id']}/view" in capex_body
assert f"projects/{project['id']}/scenarios/ui" in capex_body
opex = client.get(
f"/calculations/opex?project_id={project['id']}&scenario_id={scenario['id']}"
)
assert opex.status_code == 200
opex_body = html.unescape(opex.text)
assert f"scenarios/{scenario['id']}/view" in opex_body
assert f"projects/{project['id']}/scenarios/ui" in opex_body
def test_profitability_legacy_endpoint_redirects_to_scenario_path(client: TestClient) -> None:
project = _create_project(client, name="Redirect Project")
scenario = _create_scenario(
client,
project["id"],
name="Redirect Scenario",
status=ScenarioStatus.ACTIVE,
currency="USD",
)
response = client.get(
f"/calculations/profitability?project_id={project['id']}&scenario_id={scenario['id']}",
follow_redirects=False,
)
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
location = response.headers.get("location")
assert location is not None
expected_path = (
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
)
assert expected_path in location