Files
HAMeter/tests/test_web.py
2026-03-06 12:25:27 -05:00

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