388 lines
14 KiB
Python
388 lines
14 KiB
Python
"""Tests for the web UI routes using Flask test client."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from hameter.config import (
|
|
GeneralConfig,
|
|
HaMeterConfig,
|
|
MeterConfig,
|
|
MqttConfig,
|
|
RateComponent,
|
|
save_config,
|
|
)
|
|
from hameter.state import AppState, CostState, PipelineStatus
|
|
from hameter.web import create_app
|
|
|
|
|
|
@pytest.fixture
|
|
def app_state():
|
|
return AppState()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app_state):
|
|
app = create_app(app_state)
|
|
app.config["TESTING"] = True
|
|
with app.test_client() as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def configured_state(app_state, tmp_path, monkeypatch):
|
|
"""AppState with a config loaded."""
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
config = HaMeterConfig(
|
|
general=GeneralConfig(),
|
|
mqtt=MqttConfig(host="10.0.0.1"),
|
|
meters=[
|
|
MeterConfig(
|
|
id=123, protocol="scm", name="Electric",
|
|
unit_of_measurement="kWh",
|
|
),
|
|
],
|
|
)
|
|
save_config(config, str(tmp_path / "config.json"))
|
|
app_state.set_config(config)
|
|
app_state.config_ready.set()
|
|
app_state.set_status(PipelineStatus.RUNNING)
|
|
return app_state
|
|
|
|
|
|
class TestRedirects:
|
|
def test_root_redirects_to_setup_when_unconfigured(self, client, app_state):
|
|
response = client.get("/")
|
|
assert response.status_code == 302
|
|
assert "/setup" in response.headers["Location"]
|
|
|
|
def test_root_redirects_to_dashboard_when_configured(self, client, configured_state):
|
|
response = client.get("/")
|
|
assert response.status_code == 302
|
|
assert "/dashboard" in response.headers["Location"]
|
|
|
|
def test_setup_redirects_to_dashboard_when_configured(self, client, configured_state):
|
|
response = client.get("/setup")
|
|
assert response.status_code == 302
|
|
assert "/dashboard" in response.headers["Location"]
|
|
|
|
|
|
class TestApiStatus:
|
|
def test_status_returns_current_state(self, client, app_state):
|
|
app_state.set_status(PipelineStatus.RUNNING, "All good")
|
|
response = client.get("/api/status")
|
|
data = response.get_json()
|
|
assert data["status"] == "running"
|
|
assert data["message"] == "All good"
|
|
|
|
def test_status_unconfigured(self, client, app_state):
|
|
response = client.get("/api/status")
|
|
data = response.get_json()
|
|
assert data["status"] == "unconfigured"
|
|
|
|
|
|
class TestApiSetup:
|
|
def test_setup_saves_config(self, client, app_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
|
|
response = client.post("/api/setup", json={
|
|
"mqtt": {"host": "10.0.0.1", "port": 1883},
|
|
"meter": {"id": 123, "protocol": "scm", "name": "Test"},
|
|
})
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["ok"]
|
|
assert app_state.config_ready.is_set()
|
|
|
|
def test_setup_without_meter(self, client, app_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
|
|
response = client.post("/api/setup", json={
|
|
"mqtt": {"host": "10.0.0.1"},
|
|
})
|
|
assert response.status_code == 200
|
|
assert app_state.config is not None
|
|
assert app_state.config.meters == []
|
|
|
|
def test_setup_rejects_missing_host(self, client):
|
|
response = client.post("/api/setup", json={
|
|
"mqtt": {"port": 1883},
|
|
})
|
|
assert response.status_code == 400
|
|
|
|
|
|
class TestApiConfigMeters:
|
|
def test_list_meters(self, client, configured_state):
|
|
response = client.get("/api/config/meters")
|
|
data = response.get_json()
|
|
assert len(data) == 1
|
|
assert data[0]["id"] == 123
|
|
|
|
def test_add_meter(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.post("/api/config/meters", json={
|
|
"id": 456,
|
|
"protocol": "r900",
|
|
"name": "Water",
|
|
})
|
|
assert response.status_code == 200
|
|
assert response.get_json()["ok"]
|
|
assert len(configured_state.config.meters) == 2
|
|
|
|
def test_add_duplicate_meter_rejected(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.post("/api/config/meters", json={
|
|
"id": 123, # Already exists
|
|
"protocol": "scm",
|
|
"name": "Dup",
|
|
})
|
|
assert response.status_code == 409
|
|
|
|
def test_delete_meter(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.delete("/api/config/meters/123")
|
|
assert response.status_code == 200
|
|
assert len(configured_state.config.meters) == 0
|
|
|
|
def test_delete_nonexistent_meter(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.delete("/api/config/meters/999")
|
|
assert response.status_code == 404
|
|
|
|
def test_update_meter(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.put("/api/config/meters/123", json={
|
|
"protocol": "scm",
|
|
"name": "Updated Electric",
|
|
"multiplier": 0.5,
|
|
})
|
|
assert response.status_code == 200
|
|
assert configured_state.config.meters[0].name == "Updated Electric"
|
|
assert configured_state.config.meters[0].multiplier == 0.5
|
|
|
|
|
|
class TestApiPipeline:
|
|
def test_restart_sets_event(self, client, app_state):
|
|
response = client.post("/api/pipeline/restart")
|
|
assert response.status_code == 200
|
|
assert app_state.restart_requested.is_set()
|
|
|
|
|
|
class TestApiDiscovery:
|
|
def test_start_sets_event(self, client, app_state):
|
|
response = client.post("/api/discovery/start", json={"duration": 60})
|
|
assert response.status_code == 200
|
|
assert app_state.discovery_requested.is_set()
|
|
assert app_state.discovery_duration == 60
|
|
|
|
def test_stop_sets_event(self, client, app_state):
|
|
response = client.post("/api/discovery/stop")
|
|
assert response.status_code == 200
|
|
assert app_state.stop_discovery.is_set()
|
|
|
|
def test_results_empty(self, client, app_state):
|
|
response = client.get("/api/discovery/results")
|
|
data = response.get_json()
|
|
assert data == []
|
|
|
|
|
|
class TestApiCalibration:
|
|
def test_calculate(self, client):
|
|
response = client.post("/api/calibration/calculate", json={
|
|
"raw_reading": 516030,
|
|
"physical_reading": 59669,
|
|
})
|
|
data = response.get_json()
|
|
assert "multiplier" in data
|
|
assert abs(data["multiplier"] - 0.115633) < 0.001
|
|
|
|
def test_calculate_zero_raw(self, client):
|
|
response = client.post("/api/calibration/calculate", json={
|
|
"raw_reading": 0,
|
|
"physical_reading": 100,
|
|
})
|
|
assert response.status_code == 400
|
|
|
|
|
|
class TestApiLogs:
|
|
def test_get_logs(self, client, app_state):
|
|
app_state.add_log({"level": "INFO", "message": "test"})
|
|
response = client.get("/api/logs")
|
|
data = response.get_json()
|
|
assert len(data) >= 1
|
|
|
|
|
|
class TestApiMeterDefaults:
|
|
def test_energy_defaults(self, client):
|
|
response = client.get("/api/meter_defaults/energy")
|
|
data = response.get_json()
|
|
assert data["icon"] == "mdi:flash"
|
|
assert data["unit"] == "kWh"
|
|
|
|
def test_unknown_defaults(self, client):
|
|
response = client.get("/api/meter_defaults/unknown")
|
|
data = response.get_json()
|
|
assert data == {}
|
|
|
|
|
|
class TestApiCosts:
|
|
@pytest.fixture
|
|
def cost_state(self, app_state, tmp_path, monkeypatch):
|
|
"""AppState with a meter that has cost_factors and a cost state."""
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
monkeypatch.setattr("hameter.cost_state.COST_STATE_PATH", str(tmp_path / "cost_state.json"))
|
|
|
|
config = HaMeterConfig(
|
|
general=GeneralConfig(),
|
|
mqtt=MqttConfig(host="10.0.0.1"),
|
|
meters=[
|
|
MeterConfig(
|
|
id=123, protocol="scm", name="Electric",
|
|
unit_of_measurement="kWh",
|
|
cost_factors=[
|
|
RateComponent(name="Supply", rate=0.10, type="per_unit"),
|
|
RateComponent(name="Customer Charge", rate=9.65, type="fixed"),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
save_config(config, str(tmp_path / "config.json"))
|
|
app_state.set_config(config)
|
|
app_state.config_ready.set()
|
|
app_state.set_status(PipelineStatus.RUNNING)
|
|
app_state.update_cost_state(123, CostState(
|
|
cumulative_cost=50.0,
|
|
last_calibrated_reading=1000.0,
|
|
billing_period_start="2026-03-01T00:00:00Z",
|
|
))
|
|
return app_state
|
|
|
|
def test_get_costs(self, client, cost_state):
|
|
response = client.get("/api/costs")
|
|
data = response.get_json()
|
|
assert "123" in data
|
|
assert data["123"]["cumulative_cost"] == 50.0
|
|
|
|
def test_reset_cost(self, client, cost_state):
|
|
response = client.post("/api/costs/123/reset")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["ok"]
|
|
assert data["billing_period_start"]
|
|
cs = cost_state.get_cost_state(123)
|
|
assert cs.cumulative_cost == 0.0
|
|
assert cs.last_calibrated_reading is None
|
|
|
|
def test_add_fixed_charges(self, client, cost_state):
|
|
response = client.post("/api/costs/123/add-fixed")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["ok"]
|
|
assert data["fixed_added"] == 9.65
|
|
assert abs(data["cumulative_cost"] - 59.65) < 0.01
|
|
|
|
def test_add_fixed_charges_no_meter(self, client, cost_state):
|
|
response = client.post("/api/costs/999/add-fixed")
|
|
assert response.status_code == 404
|
|
|
|
def test_get_costs_empty(self, client, app_state):
|
|
response = client.get("/api/costs")
|
|
data = response.get_json()
|
|
assert data == {}
|
|
|
|
|
|
class TestApiMetersWithCostFactors:
|
|
def test_list_meters_includes_cost_factors(self, client, app_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
|
|
config = HaMeterConfig(
|
|
general=GeneralConfig(),
|
|
mqtt=MqttConfig(host="10.0.0.1"),
|
|
meters=[
|
|
MeterConfig(
|
|
id=123, protocol="scm", name="Electric",
|
|
unit_of_measurement="kWh",
|
|
cost_factors=[
|
|
RateComponent(name="Supply", rate=0.10, type="per_unit"),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
save_config(config, str(tmp_path / "config.json"))
|
|
app_state.set_config(config)
|
|
app_state.config_ready.set()
|
|
|
|
response = client.get("/api/config/meters")
|
|
data = response.get_json()
|
|
assert len(data) == 1
|
|
assert len(data[0]["cost_factors"]) == 1
|
|
assert data[0]["cost_factors"][0]["name"] == "Supply"
|
|
assert data[0]["cost_factors"][0]["rate"] == 0.10
|
|
|
|
def test_add_meter_with_cost_factors(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.post("/api/config/meters", json={
|
|
"id": 456,
|
|
"protocol": "r900",
|
|
"name": "Water",
|
|
"cost_factors": [
|
|
{"name": "Supply", "rate": 0.05, "type": "per_unit"},
|
|
],
|
|
})
|
|
assert response.status_code == 200
|
|
meter = configured_state.config.meters[1]
|
|
assert len(meter.cost_factors) == 1
|
|
assert meter.cost_factors[0].name == "Supply"
|
|
|
|
def test_update_meter_with_cost_factors(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.put("/api/config/meters/123", json={
|
|
"protocol": "scm",
|
|
"name": "Electric",
|
|
"cost_factors": [
|
|
{"name": "Generation", "rate": 0.14742, "type": "per_unit"},
|
|
{"name": "Customer Charge", "rate": 9.65, "type": "fixed"},
|
|
],
|
|
})
|
|
assert response.status_code == 200
|
|
meter = configured_state.config.meters[0]
|
|
assert len(meter.cost_factors) == 2
|
|
assert meter.cost_factors[0].name == "Generation"
|
|
assert meter.cost_factors[1].type == "fixed"
|
|
|
|
def test_add_meter_with_invalid_cost_factor(self, client, configured_state, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
|
|
save_config(configured_state.config, str(tmp_path / "config.json"))
|
|
|
|
response = client.post("/api/config/meters", json={
|
|
"id": 456,
|
|
"protocol": "r900",
|
|
"name": "Water",
|
|
"cost_factors": [
|
|
{"name": "", "rate": 0.05}, # Empty name
|
|
],
|
|
})
|
|
assert response.status_code == 400
|