| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645 |
- """Tests for #1251 — electricity price from Home Assistant, costed hour by hour.
- Covers:
- - reading a price sensor: units, unusable states, negative prices, fallback,
- and remembering the reading in ``energy_cost_per_kwh``
- - the cost arithmetic: each step at the price it starts from, resets skipped
- - Statistics: date-range and all-time cost follow the snapshot prices instead
- of multiplying everything by today's price
- - per-print cost across a price change, and the fallbacks for prints that
- started before the upgrade or ended on another plug
- - the start of a print recording when, from which plug and at what price
- - the one-shot backfill of prices onto existing snapshots
- """
- from datetime import datetime, timedelta, timezone
- from unittest.mock import AsyncMock, patch
- import pytest
- from sqlalchemy import select, text
- from backend.app.api.routes.archives import _sum_snapshot_deltas
- from backend.app.api.routes.settings import get_setting, set_setting
- from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
- from backend.app.services import energy_price
- from backend.app.services.energy_price import (
- all_time_cost,
- cost_at_average_price,
- cost_of_readings,
- current_price,
- new_snapshot,
- price_from_state,
- print_energy_cost,
- snapshot_cost,
- )
- T0 = datetime(2026, 9, 1, 0, 0) # naive UTC, as the column stores it
- def _snap(plug_id: int, hours: float, kwh: float, price: float | None) -> SmartPlugEnergySnapshot:
- return SmartPlugEnergySnapshot(
- plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price_per_kwh=price
- )
- def _state(value, unit: str | None = "AUD/kWh") -> dict:
- return {"state": value, "attributes": {"unit_of_measurement": unit} if unit else {}}
- class TestPriceFromState:
- def test_per_kwh_is_taken_as_is(self):
- assert price_from_state(_state("0.31")) == pytest.approx(0.31)
- def test_per_mwh_is_scaled(self):
- # Nord Pool / ENTSO-E day-ahead sensors are often per MWh.
- assert price_from_state(_state("120.5", "EUR/MWh")) == pytest.approx(0.1205)
- def test_per_wh_is_scaled(self):
- assert price_from_state(_state("0.0003", "€/Wh")) == pytest.approx(0.3)
- def test_no_unit_is_taken_as_per_kwh(self):
- assert price_from_state(_state("0.2", None)) == pytest.approx(0.2)
- @pytest.mark.parametrize("value", ["unknown", "unavailable", "", None, "nan", "inf"])
- def test_unusable_states_give_none(self, value):
- assert price_from_state(_state(value)) is None
- def test_no_state_gives_none(self):
- assert price_from_state(None) is None
- def test_negative_price_is_costed_as_free(self):
- assert price_from_state(_state("-0.05")) == 0.0
- class TestCostArithmetic:
- def test_each_step_at_the_price_it_starts_from(self):
- # 2 kWh at 0.10, then 3 kWh at 0.50; the end reading's price is unused.
- kwh, cost = cost_of_readings([(10.0, 0.10), (12.0, 0.50), (15.0, 9.99)], fallback_price=1.0)
- assert kwh == pytest.approx(5.0)
- assert cost == pytest.approx(0.2 + 1.5)
- def test_missing_price_uses_fallback(self):
- kwh, cost = cost_of_readings([(0.0, None), (2.0, None)], fallback_price=0.25)
- assert (kwh, cost) == (pytest.approx(2.0), pytest.approx(0.5))
- def test_counter_reset_step_counts_nothing(self):
- kwh, cost = cost_of_readings([(100.0, 0.1), (2.0, 0.1), (5.0, 0.1)], fallback_price=1.0)
- assert kwh == pytest.approx(3.0)
- assert cost == pytest.approx(0.3)
- def test_average_price_scales_to_the_energy_shown(self):
- assert cost_at_average_price(10.0, 5.0, 2.0, fallback_price=9.0) == pytest.approx(4.0)
- def test_average_price_without_costed_energy_uses_fallback(self):
- assert cost_at_average_price(10.0, 0.0, 0.0, fallback_price=0.3) == pytest.approx(3.0)
- class TestCurrentPrice:
- async def _configure_ha(self, db, entity="sensor.amber_price"):
- await set_setting(db, "ha_enabled", "true")
- await set_setting(db, "ha_url", "http://ha.local:8123")
- await set_setting(db, "ha_token", "token") # pragma: allowlist secret
- await set_setting(db, "energy_price_source", "homeassistant")
- await set_setting(db, "energy_price_ha_entity", entity)
- await set_setting(db, "energy_cost_per_kwh", "0.2")
- await db.commit()
- @pytest.mark.asyncio
- async def test_fixed_source_uses_the_setting(self, db_session):
- await set_setting(db_session, "energy_cost_per_kwh", "0.27")
- await db_session.commit()
- with patch.object(energy_price, "_read_state", AsyncMock()) as read:
- assert await current_price(db_session) == pytest.approx(0.27)
- read.assert_not_called()
- @pytest.mark.asyncio
- async def test_default_when_nothing_is_set(self, db_session):
- assert await current_price(db_session) == pytest.approx(0.15)
- @pytest.mark.asyncio
- async def test_reads_the_sensor_and_remembers_it(self, db_session, monkeypatch):
- monkeypatch.delenv("HA_URL", raising=False)
- monkeypatch.delenv("HA_TOKEN", raising=False)
- await self._configure_ha(db_session)
- with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))) as read:
- assert await current_price(db_session, remember=True) == pytest.approx(0.42)
- read.assert_awaited_once_with("http://ha.local:8123", "token", "sensor.amber_price")
- assert await get_setting(db_session, "energy_cost_per_kwh") == "0.42"
- @pytest.mark.asyncio
- async def test_print_paths_read_without_writing_a_setting(self, db_session, monkeypatch):
- # A setting written in the print-start transaction could cost the
- # print its energy reading if that write failed.
- monkeypatch.delenv("HA_URL", raising=False)
- monkeypatch.delenv("HA_TOKEN", raising=False)
- await self._configure_ha(db_session)
- with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))):
- assert await current_price(db_session) == pytest.approx(0.42)
- assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
- @pytest.mark.asyncio
- async def test_unreadable_sensor_keeps_the_last_price(self, db_session, monkeypatch):
- monkeypatch.delenv("HA_URL", raising=False)
- monkeypatch.delenv("HA_TOKEN", raising=False)
- await self._configure_ha(db_session)
- with patch.object(energy_price, "_read_state", AsyncMock(return_value=None)):
- assert await current_price(db_session) == pytest.approx(0.2)
- assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
- @pytest.mark.asyncio
- async def test_no_entity_does_not_read(self, db_session, monkeypatch):
- monkeypatch.delenv("HA_URL", raising=False)
- monkeypatch.delenv("HA_TOKEN", raising=False)
- await self._configure_ha(db_session, entity="")
- with patch.object(energy_price, "_read_state", AsyncMock()) as read:
- assert await current_price(db_session) == pytest.approx(0.2)
- read.assert_not_called()
- @pytest.mark.asyncio
- async def test_home_assistant_switched_off_does_not_read(self, db_session, monkeypatch):
- monkeypatch.delenv("HA_URL", raising=False)
- monkeypatch.delenv("HA_TOKEN", raising=False)
- await self._configure_ha(db_session)
- await set_setting(db_session, "ha_enabled", "false")
- await db_session.commit()
- with patch.object(energy_price, "_read_state", AsyncMock()) as read:
- assert await current_price(db_session) == pytest.approx(0.2)
- read.assert_not_called()
- async def _record(db, plug_id: int, hours: float, kwh: float, price: float) -> SmartPlugEnergySnapshot:
- """Add a snapshot the way the hourly loop does, running totals and all."""
- row = await new_snapshot(
- db, plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price=price
- )
- db.add(row)
- await db.flush()
- return row
- class TestNewSnapshot:
- @pytest.mark.asyncio
- async def test_first_snapshot_counts_the_counter_at_its_price(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- row = await _record(db_session, plug.id, 0, 100.0, 0.10)
- assert (row.kwh_to_date, row.cost_to_date) == (pytest.approx(100.0), pytest.approx(10.0))
- @pytest.mark.asyncio
- async def test_each_step_at_the_previous_snapshots_price(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10)
- await _record(db_session, plug.id, 1, 102.0, 0.50)
- row = await _record(db_session, plug.id, 2, 105.0, 0.20)
- assert row.kwh_to_date == pytest.approx(105.0)
- assert row.cost_to_date == pytest.approx(10.0 + 2 * 0.10 + 3 * 0.50)
- assert row.price_per_kwh == pytest.approx(0.20)
- @pytest.mark.asyncio
- async def test_counter_reset_adds_nothing_and_the_total_carries_on(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10)
- await _record(db_session, plug.id, 1, 2.0, 0.10) # device reset
- row = await _record(db_session, plug.id, 2, 5.0, 0.10)
- assert row.kwh_to_date == pytest.approx(103.0)
- assert row.cost_to_date == pytest.approx(10.3)
- @pytest.mark.asyncio
- async def test_continues_from_a_backfilled_row(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- # What the upgrade backfill leaves: totals are the counter at one price.
- db_session.add(
- SmartPlugEnergySnapshot(
- plug_id=plug.id,
- recorded_at=T0,
- lifetime_kwh=50.0,
- price_per_kwh=0.2,
- kwh_to_date=50.0,
- cost_to_date=10.0,
- )
- )
- await db_session.flush()
- row = await _record(db_session, plug.id, 1, 53.0, 0.9)
- assert row.kwh_to_date == pytest.approx(53.0)
- assert row.cost_to_date == pytest.approx(10.0 + 3 * 0.2)
- @pytest.mark.asyncio
- async def test_plugs_keep_their_own_totals(self, db_session, smart_plug_factory):
- a = await smart_plug_factory(name="A")
- b = await smart_plug_factory(name="B")
- await _record(db_session, a.id, 0, 10.0, 0.10)
- await _record(db_session, b.id, 0, 500.0, 1.00)
- row = await _record(db_session, a.id, 1, 11.0, 0.10)
- assert row.kwh_to_date == pytest.approx(11.0)
- assert row.cost_to_date == pytest.approx(1.1)
- class TestSnapshotCost:
- @pytest.mark.asyncio
- async def test_empty(self, db_session):
- result = await snapshot_cost(db_session, fallback_price=0.3)
- assert result.kwh == 0.0
- assert result.cost == 0.0
- @pytest.mark.asyncio
- async def test_whole_history(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10)
- await _record(db_session, plug.id, 1, 102.0, 0.50)
- await _record(db_session, plug.id, 2, 105.0, 0.20)
- await db_session.commit()
- result = await snapshot_cost(db_session, fallback_price=9.0)
- assert result.kwh == pytest.approx(105.0)
- assert result.cost == pytest.approx(100 * 0.10 + 2 * 0.10 + 3 * 0.50)
- assert result.last_lifetime_kwh == pytest.approx(105.0)
- @pytest.mark.asyncio
- async def test_range_covers_the_span_the_energy_figure_counts(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10) # baseline: last at or before dt_from
- await _record(db_session, plug.id, 2, 104.0, 0.50)
- await _record(db_session, plug.id, 4, 110.0, 0.20) # endpoint: last at or before dt_to
- await _record(db_session, plug.id, 6, 150.0, 0.90)
- await db_session.commit()
- dt_from = (T0 + timedelta(hours=1)).replace(tzinfo=timezone.utc)
- dt_to = (T0 + timedelta(hours=5)).replace(tzinfo=timezone.utc)
- result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=dt_from, dt_to=dt_to)
- energy, _ = await _sum_snapshot_deltas(db_session, dt_from=dt_from, dt_to=dt_to)
- assert result.kwh == pytest.approx(energy) == pytest.approx(10.0)
- assert result.cost == pytest.approx(4 * 0.10 + 6 * 0.50)
- @pytest.mark.asyncio
- async def test_range_without_a_baseline_starts_at_the_first_snapshot(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 2, 100.0, 0.10)
- await _record(db_session, plug.id, 3, 104.0, 0.50)
- await db_session.commit()
- result = await snapshot_cost(
- db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc), dt_to=None
- )
- assert result.kwh == pytest.approx(4.0)
- assert result.cost == pytest.approx(0.4)
- @pytest.mark.asyncio
- async def test_plugs_are_summed(self, db_session, smart_plug_factory):
- a = await smart_plug_factory(name="A")
- b = await smart_plug_factory(name="B")
- for plug_id, start, price in ((a.id, 10.0, 0.10), (b.id, 500.0, 1.00)):
- await _record(db_session, plug_id, 0, start, price)
- await _record(db_session, plug_id, 1, start + 1 + (plug_id == b.id), price)
- await db_session.commit()
- result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc))
- assert result.kwh == pytest.approx(3.0)
- assert result.cost == pytest.approx(1 * 0.10 + 2 * 1.00)
- @pytest.mark.asyncio
- async def test_reset_in_pre_upgrade_history_counts_nothing(self, db_session, smart_plug_factory):
- """Backfilled totals are the raw counter, so a reset shows as a drop."""
- plug = await smart_plug_factory(name="A")
- for hours, kwh in ((0, 1000.0), (2, 5.0)):
- db_session.add(
- SmartPlugEnergySnapshot(
- plug_id=plug.id,
- recorded_at=T0 + timedelta(hours=hours),
- lifetime_kwh=kwh,
- price_per_kwh=0.2,
- kwh_to_date=kwh,
- cost_to_date=kwh * 0.2,
- )
- )
- await db_session.commit()
- result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=(T0 + timedelta(hours=1)))
- assert (result.kwh, result.cost) == (0.0, 0.0)
- @pytest.mark.asyncio
- async def test_row_without_totals_falls_back_to_its_counter(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- db_session.add(SmartPlugEnergySnapshot(plug_id=plug.id, recorded_at=T0, lifetime_kwh=4.0))
- await db_session.commit()
- result = await snapshot_cost(db_session, fallback_price=0.25)
- assert (result.kwh, result.cost) == (pytest.approx(4.0), pytest.approx(1.0))
- class TestAllTimeCost:
- @pytest.mark.asyncio
- async def test_history_keeps_its_price_when_the_price_changes(self, db_session, smart_plug_factory):
- """The reported problem: today's price must not re-cost past energy."""
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10)
- await _record(db_session, plug.id, 1, 110.0, 0.10)
- await db_session.commit()
- # Live counter 112: 110 recorded at 0.10, 2 since the last snapshot at
- # today's 0.50.
- cost = await all_time_cost(db_session, live_total_kwh=112.0, price_now=0.50)
- assert cost == pytest.approx(110 * 0.10 + 2 * 0.50)
- @pytest.mark.asyncio
- async def test_no_snapshots_uses_the_price_now(self, db_session):
- assert await all_time_cost(db_session, live_total_kwh=20.0, price_now=0.3) == pytest.approx(6.0)
- @pytest.mark.asyncio
- async def test_live_counter_below_the_history_is_costed_at_its_average_price(self, db_session, smart_plug_factory):
- # The counter was reset since the last snapshot: the energy shown is
- # the live counter, at the average price of the history.
- plug = await smart_plug_factory(name="A")
- await _record(db_session, plug.id, 0, 100.0, 0.10)
- await _record(db_session, plug.id, 1, 200.0, 0.30)
- await db_session.commit()
- cost = await all_time_cost(db_session, live_total_kwh=50.0, price_now=9.0)
- assert cost == pytest.approx(50 * (10.0 + 10.0) / 200.0)
- class TestPrintEnergyCost:
- @pytest.mark.asyncio
- async def test_price_change_during_the_print(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- # Started at 50 kWh / 0.10 half an hour before the 1 h snapshot.
- db_session.add_all([_snap(plug.id, 1, 51.0, 0.40), _snap(plug.id, 2, 53.0, 0.05)])
- # Snapshots from before the print must not count.
- db_session.add(_snap(plug.id, 0, 49.0, 9.0))
- await db_session.commit()
- cost = await print_energy_cost(
- db_session,
- plug_id=plug.id,
- start_at=T0 + timedelta(minutes=30),
- start_kwh=50.0,
- start_price=0.10,
- end_kwh=54.0,
- end_plug_id=plug.id,
- price_now=0.05,
- )
- # 1 kWh at 0.10, 2 at 0.40, 1 at 0.05 — not 4 kWh at the end price 0.05.
- assert cost == pytest.approx(0.10 + 0.80 + 0.05)
- @pytest.mark.asyncio
- async def test_short_print_without_snapshots_uses_the_start_price(self, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- cost = await print_energy_cost(
- db_session,
- plug_id=plug.id,
- start_at=T0,
- start_kwh=10.0,
- start_price=0.30,
- end_kwh=10.5,
- end_plug_id=plug.id,
- price_now=0.90,
- )
- assert cost == pytest.approx(0.15)
- @pytest.mark.asyncio
- async def test_print_started_before_the_upgrade_uses_the_price_now(self, db_session):
- cost = await print_energy_cost(
- db_session,
- plug_id=None,
- start_at=None,
- start_kwh=10.0,
- start_price=None,
- end_kwh=12.0,
- end_plug_id=1,
- price_now=0.25,
- )
- assert cost == pytest.approx(0.5)
- @pytest.mark.asyncio
- async def test_end_reading_from_another_plug_uses_the_price_now(self, db_session):
- cost = await print_energy_cost(
- db_session,
- plug_id=1,
- start_at=T0,
- start_kwh=10.0,
- start_price=0.10,
- end_kwh=12.0,
- end_plug_id=2,
- price_now=0.25,
- )
- assert cost == pytest.approx(0.5)
- class TestStatsEndpoint:
- @pytest.mark.asyncio
- async def test_date_range_cost_uses_snapshot_prices(self, async_client, db_session, smart_plug_factory):
- plug = await smart_plug_factory(name="A")
- # T0 is 2026-09-01 00:00 UTC.
- await _record(db_session, plug.id, 23, 100.0, 0.10) # 2026-09-01 23:00, baseline
- await _record(db_session, plug.id, 30, 104.0, 0.50)
- await _record(db_session, plug.id, 36, 106.0, 0.50)
- await set_setting(db_session, "energy_tracking_mode", "total")
- await set_setting(db_session, "energy_cost_per_kwh", "3.0") # today's price, must not apply
- await db_session.commit()
- response = await async_client.get("/api/v1/archives/stats?date_from=2026-09-02&date_to=2026-09-02")
- assert response.status_code == 200
- data = response.json()
- assert data["total_energy_kwh"] == pytest.approx(6.0)
- assert data["total_energy_cost"] == pytest.approx(4 * 0.10 + 2 * 0.50)
- class TestRecordEnergyStart:
- @pytest.mark.asyncio
- async def test_records_when_where_and_at_what_price(
- self, db_session, printer_factory, smart_plug_factory, archive_factory
- ):
- printer = await printer_factory()
- plug = await smart_plug_factory(name="Power", printer_id=printer.id)
- archive = await archive_factory(printer.id)
- await set_setting(db_session, "energy_cost_per_kwh", "0.33")
- await db_session.commit()
- from backend.app.main import _record_energy_start
- with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 7.5})):
- assert await _record_energy_start(archive, printer.id, db_session) is True
- assert archive.energy_start_kwh == 7.5
- assert archive.energy_start_plug_id == plug.id
- assert archive.energy_start_price == pytest.approx(0.33)
- assert archive.energy_start_at is not None
- assert archive.energy_start_at.tzinfo is None
- class TestSnapshotPriceBackfill:
- @pytest.mark.asyncio
- async def test_fills_existing_rows_once(self, test_engine, db_session, smart_plug_factory):
- from backend.app.core.database import _backfill_snapshot_prices
- plug_id = (await smart_plug_factory(name="A")).id
- db_session.add_all([_snap(plug_id, 0, 1.0, None), _snap(plug_id, 1, 2.0, None)])
- await set_setting(db_session, "energy_cost_per_kwh", "0.28")
- await db_session.commit()
- async with test_engine.begin() as conn:
- await _backfill_snapshot_prices(conn)
- db_session.expire_all()
- rows = (
- await db_session.execute(
- select(
- SmartPlugEnergySnapshot.price_per_kwh,
- SmartPlugEnergySnapshot.kwh_to_date,
- SmartPlugEnergySnapshot.cost_to_date,
- ).order_by(SmartPlugEnergySnapshot.recorded_at)
- )
- ).all()
- assert [tuple(r) for r in rows] == [
- (pytest.approx(0.28), pytest.approx(1.0), pytest.approx(0.28)),
- (pytest.approx(0.28), pytest.approx(2.0), pytest.approx(0.56)),
- ]
- # A second run (next boot) leaves a NULL alone: the flag is set.
- db_session.add(_snap(plug_id, 2, 3.0, None))
- await db_session.commit()
- async with test_engine.begin() as conn:
- await _backfill_snapshot_prices(conn)
- nulls = (
- await conn.execute(text("SELECT COUNT(*) FROM smart_plug_energy_snapshots WHERE price_per_kwh IS NULL"))
- ).scalar_one()
- assert nulls == 1
- class TestSettingsRoute:
- @pytest.mark.asyncio
- async def test_saves_and_returns_the_price_source(self, async_client):
- response = await async_client.put(
- "/api/v1/settings/",
- json={"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.amber_price"},
- )
- assert response.status_code == 200
- assert response.json()["energy_price_source"] == "homeassistant"
- assert response.json()["energy_price_ha_entity"] == "sensor.amber_price"
- @pytest.mark.asyncio
- async def test_defaults_to_fixed(self, async_client):
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["energy_price_source"] == "fixed"
- assert response.json()["energy_price_ha_entity"] == ""
- @pytest.mark.asyncio
- @pytest.mark.parametrize("value", ["tibber", None])
- async def test_refuses_an_unknown_or_null_source(self, async_client, value):
- response = await async_client.put("/api/v1/settings/", json={"energy_price_source": value})
- assert response.status_code == 422
- # A stored "None" would have broken every later settings read.
- assert (await async_client.get("/api/v1/settings/")).status_code == 200
- @pytest.mark.asyncio
- async def test_reads_the_price_only_when_its_source_changes(self, async_client):
- body = {"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.p", "currency": "AUD"}
- with patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.3)) as read:
- await async_client.put("/api/v1/settings/", json=body)
- assert read.await_count == 1
- # The page saves every field on each change; same source, no read.
- await async_client.put("/api/v1/settings/", json={**body, "currency": "EUR"})
- assert read.await_count == 1
- await async_client.put("/api/v1/settings/", json={**body, "energy_price_ha_entity": "sensor.q"})
- assert read.await_count == 2
- class TestSnapshotCapture:
- @pytest.mark.asyncio
- async def test_every_row_of_a_tick_carries_the_price(self):
- from types import SimpleNamespace
- from unittest.mock import MagicMock
- from backend.app.services import smart_plug_manager as manager_module
- added: list[SmartPlugEnergySnapshot] = []
- class FakeSession:
- async def execute(self, *_a, **_kw):
- result = MagicMock()
- result.scalars.return_value.all.return_value = [
- SimpleNamespace(id=1, plug_type="rest", enabled=True),
- SimpleNamespace(id=2, plug_type="tasmota", enabled=True),
- ]
- # No earlier snapshot for either plug.
- result.scalar_one_or_none.return_value = None
- return result
- def add(self, obj):
- added.append(obj)
- async def commit(self):
- pass
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_a):
- return False
- manager = manager_module.SmartPlugManager()
- with (
- patch("backend.app.core.database.async_session", FakeSession),
- patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.37)) as read,
- patch.object(
- manager,
- "get_service_for_plug",
- new=AsyncMock(return_value=SimpleNamespace(get_energy=AsyncMock(return_value={"total": 5.0}))),
- ),
- ):
- await manager._capture_energy_snapshots()
- read.assert_awaited_once()
- assert read.await_args.kwargs == {"remember": True}
- assert [row.price_per_kwh for row in added] == [0.37, 0.37]
- assert [row.cost_to_date for row in added] == [pytest.approx(5.0 * 0.37)] * 2
- class TestEnergyMultiplierRange:
- """myStrom reports watt-seconds: 1 Ws = 1/3,600,000 kWh (#1251 thread)."""
- WS_TO_KWH = 1 / 3_600_000
- @pytest.mark.parametrize(
- "field",
- [
- "mqtt_power_multiplier",
- "mqtt_energy_multiplier",
- "rest_power_multiplier",
- "rest_energy_multiplier",
- "rest_energy_total_multiplier",
- ],
- )
- def test_watt_seconds_conversion_is_accepted(self, field):
- from backend.app.schemas.smart_plug import SmartPlugCreate, SmartPlugUpdate
- plug = SmartPlugCreate(
- name="myStrom", plug_type="rest", rest_on_url="http://plug/relay?state=1", **{field: self.WS_TO_KWH}
- )
- assert getattr(plug, field) == pytest.approx(self.WS_TO_KWH)
- assert getattr(SmartPlugUpdate(**{field: self.WS_TO_KWH}), field) == pytest.approx(self.WS_TO_KWH)
- def test_zero_is_still_refused(self):
- from pydantic import ValidationError
- from backend.app.schemas.smart_plug import SmartPlugCreate
- with pytest.raises(ValidationError):
- SmartPlugCreate(
- name="myStrom",
- plug_type="rest",
- rest_on_url="http://plug/relay?state=1",
- rest_energy_total_multiplier=0,
- )
- class TestStrayStoredSource:
- @pytest.mark.asyncio
- async def test_settings_still_load_and_the_price_is_fixed(self, async_client, db_session):
- # Settings restored from a backup are written as stored, unchecked.
- await set_setting(db_session, "energy_price_source", "tibber")
- await set_setting(db_session, "energy_cost_per_kwh", "0.2")
- await db_session.commit()
- assert (await async_client.get("/api/v1/settings/")).status_code == 200
- with patch.object(energy_price, "_read_state", AsyncMock()) as read:
- assert await current_price(db_session) == pytest.approx(0.2)
- read.assert_not_called()
|