Files
houseplan-card/tests_backend/test_trail_recorder.py
T
Matysh 4e3d8f1d53 Test harness: run async recorder regressions on a private event loop
asyncio.run() clears the thread's current-loop slot when it finishes; the
CI HA harness keeps a session event loop, so every test that followed the
new HP-1540-05 regression failed at SETUP with 'There is no current event
loop'. The pure-only local run never sees the harness and stayed green —
which is exactly how it slipped through. The regressions now spin up an
isolated loop and leave the ambient one untouched.
2026-07-31 13:12:14 +03:00

320 lines
12 KiB
Python

"""TrailRecorder wiring: subscription callback, dialects, run end.
Loads trails.py with STUBBED Home Assistant modules — but only inside a
snapshot of sys.modules that is restored immediately afterwards. Injecting
fake `homeassistant` modules globally poisons the real HA harness running in
the same pytest session (it did, once).
"""
import sys, types, pathlib, importlib.util
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _load_trails():
saved = {k: v for k, v in sys.modules.items() if k == "homeassistant" or k.startswith(("homeassistant.", "houseplan"))}
try:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
ha = types.ModuleType("homeassistant"); sys.modules["homeassistant"] = ha
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda f: f
sys.modules["homeassistant.core"] = core
sys.modules["homeassistant.helpers"] = types.ModuleType("homeassistant.helpers")
er = types.ModuleType("homeassistant.helpers.entity_registry")
er.async_get = lambda hass: None
er.async_entries_for_device = lambda reg, dev: []
sys.modules["homeassistant.helpers.entity_registry"] = er
ev = types.ModuleType("homeassistant.helpers.event")
ev.async_call_later = lambda hass, delay, cb: (lambda: None)
ev.async_track_state_change_event = lambda hass, ents, cb: (lambda: None)
sys.modules["homeassistant.helpers.event"] = ev
stm = types.ModuleType("homeassistant.helpers.storage")
class _Store:
def __init__(self, *a, **k): pass
async def async_load(self): return None
async def async_save(self, d): pass
stm.Store = _Store
sys.modules["homeassistant.helpers.storage"] = stm
pkg = types.ModuleType("houseplan"); pkg.__path__ = [str(ROOT / "custom_components" / "houseplan")]
sys.modules["houseplan"] = pkg
c = types.ModuleType("houseplan.const"); c.DOMAIN = "houseplan"
sys.modules["houseplan.const"] = c
spec = importlib.util.spec_from_file_location(
"houseplan.trails", str(ROOT / "custom_components" / "houseplan" / "trails.py")
)
mod = importlib.util.module_from_spec(spec)
sys.modules["houseplan.trails"] = mod
spec.loader.exec_module(mod)
return mod
finally:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
sys.modules.update(saved)
trails = _load_trails()
class S:
def __init__(self, state, attrs): self.state, self.attributes = state, attrs
class States:
def __init__(self, d): self.d = d
def get(self, k): return self.d.get(k)
class Bus:
def __init__(self): self.fired = []
def async_fire(self, *a): self.fired.append(a)
class Hass:
def __init__(self, states): self.states, self.bus, self.data = States(states), Bus(), {}
def _rec():
states = {
"vacuum.x50": S("cleaning", {"selected_map": "Первый этаж"}),
"camera.map": S("idle", {"vacuum_position": {"x": 1000, "y": -500, "a": 90}, "map_index": 1}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": [("m1", "vacuum.x50")]}
return rec, hass, states
class E:
def __init__(self, eid): self.data = {"entity_id": eid}
def test_state_events_record_points_and_map_id():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["camera.map"] = S("idle", {"vacuum_position": {"x": 1100, "y": -500}, "map_index": 1})
rec._on_state(E("camera.map"))
run = rec.book.data["m1"]["current"]
assert run["points"] == [[1000.0, -500.0], [1100.0, -500.0]]
assert run["map_id"] == "1"
assert hass.bus.fired, "live cards must be notified"
def test_docking_ends_the_run():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["vacuum.x50"] = S("docked", {})
rec._on_state(E("vacuum.x50"))
assert rec.book.data["m1"]["current"]["ended"] is not None
def test_sample_seeds_a_run_already_in_progress():
# HA restarted mid-cleanup: the first point must not wait for an event
rec, _hass, _states = _rec()
assert rec._sample("camera.map", 123.0)
assert rec.book.data["m1"]["current"]["points"] == [[1000.0, -500.0]]
def test_junk_position_ignored():
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": "nope", "y": 1}})
assert not rec._sample("camera.map", 1.0)
states["camera.map"] = S("idle", {})
assert not rec._sample("camera.map", 1.0)
def test_unknown_source_is_noop():
rec, _hass, _states = _rec()
assert not rec._sample("camera.other", 1.0)
def test_object_style_position_is_read():
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
class Point:
def __init__(self, x, y): self.x, self.y = x, y
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": Point(2020, 3096), "map_index": 1})
assert rec._sample("camera.map", 5.0)
assert rec.book.data["m1"]["current"]["points"] == [[2020.0, 3096.0]]
def test_unavailable_vacuum_is_no_verdict():
# HA boot: the vacuum reads unavailable — the open run must NOT be ended
rec, _hass, states = _rec()
rec._sample("camera.map", 1.0)
states["vacuum.x50"] = S("unavailable", {})
assert not rec._sample("camera.map", 2.0)
assert rec.book.data["m1"]["current"]["ended"] is None
del states["vacuum.x50"]
assert not rec._sample("camera.map", 3.0)
assert rec.book.data["m1"]["current"]["ended"] is None
# ---------------- v1.54.0 audit regressions ----------------
def _run_isolated(coro):
"""Run a coroutine on a private loop WITHOUT touching the ambient one.
asyncio.run() clears the thread's current-loop slot when it finishes; in
the CI HA harness (pytest-asyncio keeps a session event loop) that
poisoned the setup of every test that followed — 'There is no current
event loop in thread MainThread' across whole files.
"""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def test_map_index_zero_matches_frontend_contract():
# HP-1540-02: `map_index: 0` is a VALID first map. The old or-chain
# dropped it and fell through to selected_map — the server stored the
# run under a key the renderer never looked up.
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}, "map_index": 0})
assert rec._sample("camera.map", 1.0)
assert rec.book.data["m1"]["current"]["map_id"] == "0"
def test_map_id_contract_first_not_none_wins():
# HP-1540-02: the shared contract — first NOT-None value, stringified
cases = [
({"map_index": 0}, {"selected_map": "Floor"}, "0"),
({"map_index": "0"}, {}, "0"),
({"map_name": ""}, {"selected_map": "Floor"}, ""),
({"map_name": "A", "map_index": 0}, {}, "A"),
({"current_map": 2}, {}, "2"),
({"selected_map": "Src"}, {"selected_map": "Vac"}, "Src"),
({}, {"selected_map": "Vac"}, "Vac"),
({}, {}, "default"),
]
for src_attrs, vac_attrs, want in cases:
assert trails.resolve_map_id(src_attrs, vac_attrs) == want, (src_attrs, vac_attrs)
def test_one_source_two_floor_markers_both_record():
# HP-1540-03: the multi-floor case — the same robot placed on two floors.
# Both markers must receive the server-side run, on every map.
states = {
"vacuum.x50": S("cleaning", {}),
"camera.map": S("idle", {"vacuum_position": {"x": 100, "y": 200}, "map_index": 0}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": [("m_floor1", "vacuum.x50"), ("m_floor2", "vacuum.x50")]}
rec._on_state(E("camera.map"))
# the robot moves to the second map: BOTH books rotate to the new run
states["camera.map"] = S("idle", {"vacuum_position": {"x": 300, "y": 400}, "map_index": 1})
rec._on_state(E("camera.map"))
for marker in ("m_floor1", "m_floor2"):
book = rec.book.data[marker]
assert book["previous"]["map_id"] == "0", marker
assert book["previous"]["points"] == [[100.0, 200.0]], marker
assert book["current"]["map_id"] == "1", marker
assert book["current"]["points"] == [[300.0, 400.0]], marker
def test_refresh_builds_pair_lists_and_dedups_subscription():
# HP-1540-03: two markers over one source/vacuum → one entity set, both pairs
import asyncio
tracked = []
def track(hass, ents, cb):
tracked.append(list(ents))
return lambda: None
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
cfgm = [
{"id": "m_f1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
{"id": "m_f2", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]
class CS:
async def async_load(self):
return {"config": {"markers": cfgm}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
rec = trails.TrailRecorder(hass, RT())
_run_isolated(rec.async_refresh())
assert rec.pairs == {"camera.map": [("m_f1", "vacuum.x50"), ("m_f2", "vacuum.x50")]}
assert tracked == [["camera.map", "vacuum.x50"]]
finally:
trails.async_track_state_change_event = old_track
def test_overlapping_refreshes_leave_one_subscription_teardown_zero():
# HP-1540-05: two config/set refreshes racing across the awaited load used
# to BOTH subscribe; teardown removed only the last handle and the other
# callback leaked until HA restart.
import asyncio
active = []
seq = {"n": 0}
def track(hass, ents, cb):
seq["n"] += 1
hid = seq["n"]
active.append(hid)
return lambda: active.remove(hid)
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
gate = asyncio.Event()
class CS:
async def async_load(self):
await gate.wait()
return {"config": {"markers": [
{"id": "m1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
async def scenario():
rec = trails.TrailRecorder(hass, RT())
t1 = asyncio.ensure_future(rec.async_refresh())
t2 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3): # both tasks are launched; one parks on the gate
await asyncio.sleep(0)
gate.set()
await t1
await t2
assert len(active) == 1, f"exactly one live subscription, got {active}"
# teardown during an in-flight refresh must also end with zero
gate.clear()
t3 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3):
await asyncio.sleep(0)
rec.teardown()
gate.set()
await t3
assert active == [], f"teardown must leave zero subscriptions, got {active}"
_run_isolated(scenario())
finally:
trails.async_track_state_change_event = old_track