Files
bambuddy/backend/tests/unit/test_main_plug_rank_2830.py
maziggy 9a2b811566 Show the plug that powers the printer in the card's Power row (#2830)
A printer card has one Power row: a plug name, its draw, and the auto-off
and on/off buttons. Which plug filled it was decided by nothing -- the
endpoint returned the first row the database handed back that was not a
Home Assistant script, from a query with no ORDER BY.

For the reporter that was an enclosure exhaust fan, added before the
outlet their X1C is plugged into. The card showed the fan's name with
'--' for watts, offered to switch the printer off by cutting the fan,
and demoted the metered outlet to the small HA button row. The fan was
marked as not powering the printer and hidden from the card; neither
setting was consulted here, though controls_printer_power has decided
the scheduler's power-on pick since #2629.

Rank the candidates instead: switchable at all, controls_printer_power,
enabled, show_on_printer_card, reports power, lowest id. The first rules
out a script, which can only be run, and an MQTT plug, which the control
endpoint rejects as monitor-only -- and an MQTT plug is exactly the kind
that reports watts, so without it ahead of the power tiebreak the row
could land on a plug whose on/off button answers with an error. The last
is not cosmetic: with no ORDER BY, a plain UPDATE on PostgreSQL can move
a row and silently swap which plug the card calls the printer's power.

None of these excludes a plug. A printer whose only plug is hidden,
disabled or monitor-only still needs its Power row, because that row
holds the on/off button and the HA buttons are drawn inside it.
controls_printer_power sits above show_on_printer_card because the two
only disagree when the plug that really feeds the printer is hidden, and
letting a display preference win there points the power buttons at an
accessory -- the fault #2629 fixed. Power capability is read from the
configuration, not measured: this runs on every card render, and it is
approximate both ways, so it only breaks a tie.

The scripts endpoint shares the same pick and excludes it, so a
switchable main plug is not repeated as a button directly below itself.
A script is left in place: a printer whose only entities are scripts
falls back to showing one in the power row, and taking it out of the
button row too would cost it the one-click run it has always had.
2026-08-14 14:49:55 +02:00

260 lines
11 KiB
Python

"""Choosing which of a printer's plugs is "the" power plug (#2830).
The printer card's Power row carries the power on/off and auto-off-after-print
controls, so it has to land on the plug that actually feeds the printer. It used
to take the first non-script row the database happened to return, which put an
exhaust fan -- flagged as not powering the printer, and explicitly hidden from
the card -- in front of the outlet the printer is plugged into.
"""
from types import SimpleNamespace
import pytest
from backend.app.api.routes.smart_plugs import (
_can_be_switched,
_is_script_plug,
_main_plug_rank,
_pick_main_plug,
_reports_power,
)
pytestmark = pytest.mark.unit
def _plug(plug_id=1, **kwargs):
"""A plug with every flag at its model default, overridable per test."""
defaults = {
"id": plug_id,
"name": f"Plug {plug_id}",
"plug_type": "tasmota",
"ha_entity_id": None,
"ha_power_entity": None,
"mqtt_topic": None,
"mqtt_power_topic": None,
"rest_power_path": None,
"controls_printer_power": True,
"enabled": True,
"show_on_printer_card": True,
}
return SimpleNamespace(**{**defaults, **kwargs})
class TestTheReportedCase:
def test_the_printers_outlet_beats_an_exhaust_fan(self):
"""The reporter's two Home Assistant plugs on one X1C. The fan was
created first, so with no ranking it won on row order alone."""
fan = _plug(
1,
name="Print Farm Exhaust Fan",
plug_type="homeassistant",
ha_entity_id="switch.print_farm_exhaust_fan",
controls_printer_power=False,
show_on_printer_card=False,
)
outlet = _plug(
2,
name="Bambu X1C Outlet",
plug_type="homeassistant",
ha_entity_id="switch.bambu_x1c_outlet",
ha_power_entity="sensor.bambu_x1c_outlet_power",
)
assert _pick_main_plug([fan, outlet]) is outlet
def test_and_it_wins_whichever_order_they_arrive_in(self):
fan = _plug(1, plug_type="homeassistant", ha_entity_id="switch.fan", controls_printer_power=False)
outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
assert _pick_main_plug([outlet, fan]) is outlet
class TestTheOrderOfPreference:
def test_a_switch_beats_a_script(self):
"""A script cannot be switched off, so it can never be the power plug."""
script = _plug(1, plug_type="homeassistant", ha_entity_id="script.start_print")
switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
assert _pick_main_plug([script, switch]) is switch
def test_a_switchable_plug_beats_a_monitor_only_mqtt_plug(self):
"""``control_smart_plug`` rejects MQTT plugs as monitor-only, so the
row's on/off button would answer with an error. An MQTT plug is also
the kind that reports watts, so it would otherwise win on rank 5 --
this is the one pair where the tiebreak could have done real harm."""
monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
assert _pick_main_plug([monitor, switch]) is switch
def test_a_monitor_only_plug_still_holds_the_row_on_its_own(self):
"""Rank, not filter: a printer whose only plug is an MQTT monitor keeps
the wattage readout it has always had."""
monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
assert _pick_main_plug([monitor]) is monitor
def test_a_power_plug_beats_an_accessory(self):
accessory = _plug(1, controls_printer_power=False)
outlet = _plug(2)
assert _pick_main_plug([accessory, outlet]) is outlet
def test_an_enabled_plug_beats_a_disabled_one(self):
"""A disabled plug ignores automation -- its auto-off toggle on the card
would sit there doing nothing."""
disabled = _plug(1, enabled=False)
live = _plug(2)
assert _pick_main_plug([disabled, live]) is live
def test_a_visible_plug_beats_a_hidden_one(self):
hidden = _plug(1, show_on_printer_card=False)
visible = _plug(2)
assert _pick_main_plug([hidden, visible]) is visible
def test_powering_the_printer_outranks_being_visible(self):
"""The two only disagree when someone hides the plug that really feeds
the printer. Letting the display flag win would hand the power buttons
to an accessory, which is the harm #2629 fixed for the scheduler."""
visible_accessory = _plug(1, controls_printer_power=False, show_on_printer_card=True)
hidden_outlet = _plug(2, controls_printer_power=True, show_on_printer_card=False)
assert _pick_main_plug([visible_accessory, hidden_outlet]) is hidden_outlet
def test_a_plug_that_reports_watts_breaks_a_tie(self):
"""Otherwise the row reads "--" while a plug that knows the answer sits
one rank below it."""
mute = _plug(1, plug_type="homeassistant", ha_entity_id="switch.a")
metered = _plug(2, plug_type="homeassistant", ha_entity_id="switch.b", ha_power_entity="sensor.b_power")
assert _pick_main_plug([mute, metered]) is metered
def test_reporting_watts_does_not_outrank_powering_the_printer(self):
metered_accessory = _plug(1, ha_power_entity="sensor.fan_power", controls_printer_power=False)
mute_outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
assert _pick_main_plug([metered_accessory, mute_outlet]) is mute_outlet
class TestDeterminism:
def test_equal_plugs_resolve_by_id(self):
"""The query had no ORDER BY, so on Postgres an unrelated UPDATE could
move a row and silently swap which plug the card called the printer's
power."""
first = _plug(1)
second = _plug(2)
assert _pick_main_plug([second, first]) is first
def test_the_documented_order_holds_end_to_end(self):
"""One plug per criterion, each failing only that one, ranked together.
Pairwise tests would pass against an order with the middle two swapped."""
ideal = _plug(6)
mute = _plug(5, plug_type="homeassistant", ha_entity_id="switch.mute")
hidden = _plug(4, show_on_printer_card=False)
disabled = _plug(3, enabled=False)
accessory = _plug(2, controls_printer_power=False)
script = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
ranked = sorted([script, accessory, disabled, hidden, mute, ideal], key=_main_plug_rank)
assert ranked == [ideal, mute, hidden, disabled, accessory, script]
class TestNoPlugs:
def test_no_plugs_means_no_main_plug(self):
assert _pick_main_plug([]) is None
def test_all_scripts_still_yields_one(self):
"""Pre-existing behaviour: a printer whose only entities are scripts
still gets a Power row, because the card nests everything else inside
it. Ranking must not turn that into an empty card."""
second = _plug(2, plug_type="homeassistant", ha_entity_id="script.b")
first = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
assert _pick_main_plug([second, first]) is first
class TestSwitchability:
@pytest.mark.parametrize(
"plug,expected",
[
(_plug(plug_type="tasmota"), True),
(_plug(plug_type="rest"), True),
(_plug(plug_type="homeassistant", ha_entity_id="switch.outlet"), True),
(_plug(plug_type="homeassistant", ha_entity_id="light.chamber"), True),
(_plug(plug_type="homeassistant", ha_entity_id="script.start"), False),
(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug"), False),
],
)
def test_matches_what_the_control_endpoint_accepts(self, plug, expected):
assert _can_be_switched(plug) is expected
class TestUnsetFlags:
"""An upgraded database adds these columns by ALTER, so they are nullable
there even though a fresh one declares them NOT NULL. Every row is
backfilled with the default and nothing writes a null, but a rank that
raised on one would take down the whole printers page."""
def test_a_plug_with_null_flags_ranks_last_rather_than_raising(self):
unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
ordinary = _plug(2)
assert _pick_main_plug([unset, ordinary]) is ordinary
def test_and_still_holds_the_row_when_it_is_the_only_plug(self):
unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
assert _pick_main_plug([unset]) is unset
def test_a_plug_with_no_type_at_all_does_not_raise(self):
assert _pick_main_plug([_plug(1, plug_type=None)]) is not None
class TestScriptDetection:
@pytest.mark.parametrize(
"entity_id,expected",
[
("script.start_print", True),
("switch.outlet", False),
("light.chamber", False),
(None, False),
],
)
def test_only_ha_script_entities_count(self, entity_id, expected):
assert _is_script_plug(_plug(plug_type="homeassistant", ha_entity_id=entity_id)) is expected
def test_a_tasmota_plug_is_never_a_script(self):
assert _is_script_plug(_plug(plug_type="tasmota", ha_entity_id="script.confusing")) is False
class TestPowerCapability:
"""Read from configuration, not measured -- this runs on every card render.
It is a floor: an HA plug with no dedicated sensor may still report watts
from the switch entity's own ``current_power_w`` attribute, which only a
live read would show. Good enough for a tiebreak, and it never decides
anything on its own.
"""
def test_tasmota_reports_power_natively(self):
assert _reports_power(_plug(plug_type="tasmota")) is True
def test_home_assistant_needs_a_power_sensor(self):
assert _reports_power(_plug(plug_type="homeassistant", ha_entity_id="switch.a")) is False
assert _reports_power(_plug(plug_type="homeassistant", ha_power_entity="sensor.a_power")) is True
def test_mqtt_accepts_either_the_new_or_the_legacy_topic(self):
assert _reports_power(_plug(plug_type="mqtt")) is False
assert _reports_power(_plug(plug_type="mqtt", mqtt_power_topic="tele/plug/SENSOR")) is True
assert _reports_power(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug")) is True
def test_rest_needs_a_power_path(self):
"""The REST service returns no energy at all without one, so a URL on
its own proves nothing."""
assert _reports_power(_plug(plug_type="rest")) is False
assert _reports_power(_plug(plug_type="rest", rest_power_path="apower")) is True