"""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