mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
945 lines
35 KiB
Python
945 lines
35 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, json
|
|
|
|
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_refresh_persists_and_announces_a_new_startup_sample_once():
|
|
scheduled = []
|
|
saved = []
|
|
old_call_later = trails.async_call_later
|
|
trails.async_call_later = lambda hass, delay, cb: (
|
|
scheduled.append((delay, cb)) or (lambda: None)
|
|
)
|
|
try:
|
|
class CS:
|
|
async def async_load(self):
|
|
return {"config": {"markers": [{
|
|
"id": "m1",
|
|
"binding": "entity:vacuum.x50",
|
|
"vacuum": {"source": "camera.map"},
|
|
}]}}
|
|
|
|
class RT:
|
|
config_store = CS()
|
|
|
|
class TrailStore:
|
|
async def async_save(self, data):
|
|
saved.append(json.loads(json.dumps(data)))
|
|
|
|
rec, hass, _states = _rec()
|
|
rec.rt = RT()
|
|
rec.store = TrailStore()
|
|
rec.pairs = {}
|
|
|
|
_run_isolated(rec.async_refresh())
|
|
assert len(scheduled) == 1
|
|
assert scheduled[0][0] == trails.SAVE_DELAY_S
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
_run_isolated(scheduled[0][1](None))
|
|
assert saved[-1]["m1"]["current"]["points"] == [[1000.0, -500.0]]
|
|
|
|
# The same point is a true no-op: no second save or live-card event.
|
|
_run_isolated(rec.async_refresh())
|
|
assert len(scheduled) == 1
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
finally:
|
|
trails.async_call_later = old_call_later
|
|
|
|
|
|
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_trail_book_delete_forgets_current_and_previous_runs():
|
|
book = trails.TrailBook({
|
|
"m1": {"current": {"points": [[1, 2]]}, "previous": {"points": [[3, 4]]}},
|
|
"m2": {"current": {"points": [[5, 6]]}},
|
|
})
|
|
assert book.delete("m1") is True
|
|
assert "m1" not in book.data
|
|
assert "m2" in book.data
|
|
assert book.delete("m1") is False
|
|
|
|
|
|
def test_missing_trail_delete_does_not_mutate_live_tracking_pairs():
|
|
rec, _hass, _states = _rec()
|
|
before = {src: list(pairs) for src, pairs in rec.pairs.items()}
|
|
unsubscribed = []
|
|
rec._unsub_track = lambda: unsubscribed.append(True)
|
|
assert _run_isolated(rec.async_delete("m1")) is False
|
|
assert rec.pairs == before
|
|
assert unsubscribed == []
|
|
|
|
|
|
def test_trail_delete_prunes_pair_and_replaces_subscription():
|
|
tracked = []
|
|
old_track = trails.async_track_state_change_event
|
|
trails.async_track_state_change_event = lambda hass, ents, cb: (
|
|
tracked.append(list(ents)) or (lambda: None)
|
|
)
|
|
try:
|
|
rec, _hass, _states = _rec()
|
|
rec.pairs["other.source"] = [("m2", "vacuum.other")]
|
|
rec.book.data["m1"] = {"current": {"points": [[1, 2]]}}
|
|
unsubscribed = []
|
|
rec._unsub_track = lambda: unsubscribed.append(True)
|
|
assert _run_isolated(rec.async_delete("m1")) is True
|
|
assert rec.pairs == {"other.source": [("m2", "vacuum.other")]}
|
|
assert unsubscribed == [True]
|
|
assert tracked == [["other.source", "vacuum.other"]]
|
|
finally:
|
|
trails.async_track_state_change_event = old_track
|
|
|
|
|
|
def test_orphan_purge_treats_removed_as_absent_and_batches_one_store_write():
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = {
|
|
"tombstone": {"current": {"points": [[1, 2]]}},
|
|
"hard_drop": {"current": {"points": [[3, 4]]}},
|
|
"live": {"current": {"points": [[5, 6]]}},
|
|
"hidden": {"current": {"points": [[7, 8]]}},
|
|
}
|
|
rec.pairs = {
|
|
"camera.map": [
|
|
("tombstone", "vacuum.x50"),
|
|
("hard_drop", "vacuum.x50"),
|
|
("live", "vacuum.x50"),
|
|
("hidden", "vacuum.x50"),
|
|
]
|
|
}
|
|
saved = []
|
|
|
|
class TrailStore:
|
|
async def async_save(self, data):
|
|
saved.append(json.loads(json.dumps(data)))
|
|
|
|
rec.store = TrailStore()
|
|
removed = _run_isolated(rec.async_purge_orphans({"markers": [
|
|
{"id": "tombstone", "removed": True},
|
|
{"id": "live"},
|
|
{"id": "hidden", "hidden": True},
|
|
]}))
|
|
|
|
assert removed == 2
|
|
assert set(rec.book.data) == {"live", "hidden"}
|
|
assert rec.pairs == {
|
|
"camera.map": [("live", "vacuum.x50"), ("hidden", "vacuum.x50")]
|
|
}
|
|
assert len(saved) == 1
|
|
assert set(saved[0]) == {"live", "hidden"}
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
|
|
|
|
def test_failed_orphan_store_write_rolls_back_and_can_be_retried():
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = {
|
|
"orphan": {"current": {"points": [[1, 2]]}},
|
|
"live": {"current": {"points": [[3, 4]]}},
|
|
}
|
|
rec.pairs = {
|
|
"camera.map": [
|
|
("orphan", "vacuum.x50"),
|
|
("live", "vacuum.x50"),
|
|
]
|
|
}
|
|
|
|
class FailingStore:
|
|
async def async_save(self, _data):
|
|
raise OSError("disk full")
|
|
|
|
rec.store = FailingStore()
|
|
assert _run_isolated(rec.async_purge_orphans({"markers": [{"id": "live"}]})) == 0
|
|
assert set(rec.book.data) == {"orphan", "live"}
|
|
assert rec.pairs["camera.map"] == [
|
|
("orphan", "vacuum.x50"),
|
|
("live", "vacuum.x50"),
|
|
]
|
|
assert hass.bus.fired == []
|
|
|
|
saved = []
|
|
|
|
class WorkingStore:
|
|
async def async_save(self, data):
|
|
saved.append(json.loads(json.dumps(data)))
|
|
|
|
rec.store = WorkingStore()
|
|
assert _run_isolated(rec.async_purge_orphans({"markers": [{"id": "live"}]})) == 1
|
|
assert set(rec.book.data) == {"live"}
|
|
assert set(saved[-1]) == {"live"}
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
|
|
|
|
def _route_book():
|
|
"""One live marker: a run filed under a route that is about to vanish, one to keep."""
|
|
return {
|
|
"live": {
|
|
"current": {"route_id": "vr_old", "map_id": "1", "points": [[1, 2]]},
|
|
"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]},
|
|
},
|
|
}
|
|
|
|
|
|
def _live_marker_without_vr_old():
|
|
return {
|
|
"id": "live", "space": "ground",
|
|
"vacuum": {"map_routes": [
|
|
{"id": "vr_keep", "source": "camera.map", "map_id": "1", "space": "ground"},
|
|
]},
|
|
}
|
|
|
|
|
|
class _RecordingStore:
|
|
def __init__(self):
|
|
self.saved = []
|
|
|
|
async def async_save(self, data):
|
|
self.saved.append(json.loads(json.dumps(data)))
|
|
|
|
|
|
def test_issue_495_dropped_route_runs_reach_the_store_without_orphans():
|
|
"""#495 AC3: deleting a route persists the drop even when no marker is orphaned."""
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = _route_book()
|
|
rec.pairs = {"camera.map": [("live", "vacuum.x50")]}
|
|
rec.store = _RecordingStore()
|
|
pending_cancelled = []
|
|
rec._unsub_save = lambda: pending_cancelled.append(True)
|
|
|
|
removed = _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]}))
|
|
|
|
assert removed == 0, "the return value still counts markers, not runs"
|
|
assert len(rec.store.saved) == 1
|
|
assert rec.store.saved[0] == {
|
|
"live": {"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}},
|
|
}
|
|
assert pending_cancelled == [True] and rec._unsub_save is None
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
# Restart model: a fresh book from what the store holds has no vr_old run.
|
|
restarted = trails.TrailBook(json.loads(json.dumps(rec.store.saved[-1])))
|
|
assert restarted.data["live"].get("current") is None
|
|
assert restarted.data["live"]["previous"]["route_id"] == "vr_keep"
|
|
# No change → no write, no event.
|
|
_run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]}))
|
|
assert len(rec.store.saved) == 1
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
|
|
|
|
def test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails():
|
|
"""#495 AC4: memory follows the store — a failed write keeps the run for a retry."""
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = _route_book()
|
|
rec.pairs = {"camera.map": [("live", "vacuum.x50")]}
|
|
|
|
class FailingStore:
|
|
async def async_save(self, _data):
|
|
raise OSError("disk full")
|
|
|
|
rec.store = FailingStore()
|
|
assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0
|
|
assert rec.book.data == _route_book()
|
|
assert hass.bus.fired == []
|
|
|
|
rec.store = _RecordingStore()
|
|
assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0
|
|
assert rec.store.saved[-1]["live"] == {
|
|
"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]},
|
|
}
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
|
|
|
|
def test_issue_495_dropped_route_runs_share_the_orphan_transaction():
|
|
"""#495 AC5: orphans and dropped routes leave in one write with one event."""
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}}
|
|
rec.pairs = {"camera.map": [("live", "vacuum.x50"), ("orphan", "vacuum.x50")]}
|
|
rec.store = _RecordingStore()
|
|
|
|
removed = _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]}))
|
|
|
|
assert removed == 1
|
|
assert len(rec.store.saved) == 1
|
|
assert rec.store.saved[0] == {
|
|
"live": {"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}},
|
|
}
|
|
assert rec.pairs == {"camera.map": [("live", "vacuum.x50")]}
|
|
assert hass.bus.fired == [("houseplan_trail_updated", {})]
|
|
|
|
|
|
def test_issue_495_failed_orphan_transaction_restores_dropped_route_runs_too():
|
|
"""#495 AC4 in the orphan path: rollback covers both halves of the write."""
|
|
rec, hass, _states = _rec()
|
|
rec.book.data = {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}}
|
|
rec.pairs = {"camera.map": [("live", "vacuum.x50"), ("orphan", "vacuum.x50")]}
|
|
|
|
class FailingStore:
|
|
async def async_save(self, _data):
|
|
raise OSError("disk full")
|
|
|
|
rec.store = FailingStore()
|
|
assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0
|
|
assert rec.book.data == {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}}
|
|
assert hass.bus.fired == []
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_short_available_stops_resume_one_run_and_neutral_states_do_not_shift_window():
|
|
rec, _hass, states = _rec()
|
|
assert rec._sample("camera.map", 100.0)
|
|
states["vacuum.x50"] = S("docked", {})
|
|
assert rec._sample("camera.map", 200.0)
|
|
ended = rec.book.data["m1"]["current"]["ended"]
|
|
assert ended == 200.0
|
|
assert not rec._sample("camera.map", 300.0) # repeated stop is idempotent
|
|
assert rec.book.data["m1"]["current"]["ended"] == ended
|
|
states["vacuum.x50"] = S("unknown", {})
|
|
assert not rec._sample("camera.map", 400.0)
|
|
del states["vacuum.x50"]
|
|
assert not rec._sample("camera.map", 500.0)
|
|
assert rec.book.data["m1"]["current"]["ended"] == ended
|
|
|
|
states["vacuum.x50"] = S("cleaning", {"selected_map": "Первый этаж"})
|
|
states["camera.map"] = S(
|
|
"idle", {"vacuum_position": {"x": 1100, "y": -450}, "map_index": 1}
|
|
)
|
|
assert rec._sample("camera.map", 800.0)
|
|
run = rec.book.data["m1"]["current"]
|
|
assert run["ended"] is None
|
|
assert run["points"] == [[1000.0, -500.0], [1100.0, -450.0]]
|
|
assert "previous" not in rec.book.data["m1"]
|
|
|
|
|
|
def test_any_available_nonmoving_state_uses_the_same_grace_contract():
|
|
for stopped in ("paused", "idle", "error", "washing", "docked"):
|
|
rec, _hass, states = _rec()
|
|
assert rec._sample("camera.map", 1.0)
|
|
states["vacuum.x50"] = S(stopped, {})
|
|
assert rec._sample("camera.map", 2.0), stopped
|
|
states["vacuum.x50"] = S("cleaning", {})
|
|
states["camera.map"] = S(
|
|
"idle", {"vacuum_position": {"x": 1200, "y": -400}, "map_index": 1}
|
|
)
|
|
assert rec._sample("camera.map", 3.0), stopped
|
|
assert rec.book.data["m1"]["current"]["points"] == [
|
|
[1000.0, -500.0], [1200.0, -400.0],
|
|
]
|
|
assert "previous" not in rec.book.data["m1"]
|
|
|
|
|
|
# ---------------- 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_vacuum_selected_map_zero_fallback_recorded_as_zero():
|
|
# HP-1541-01: source names no map, vacuum reports selected_map: 0 — the
|
|
# recorder must store the run under "0", the same id the fixed card-side
|
|
# fallback (vacMapIdWithFallback) resolves. Before the fix the card asked
|
|
# for calibration/trails under "default" and never found this run.
|
|
rec, _hass, states = _rec()
|
|
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}})
|
|
states["vacuum.x50"] = S("cleaning", {"selected_map": 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"),
|
|
# HP-1541-01: the vacuum-entity fallback with a zero-ish id — must
|
|
# match the card's vacMapIdWithFallback (test/vacuum.test.mjs)
|
|
({}, {"selected_map": 0}, "0"),
|
|
({}, {"selected_map": "0"}, "0"),
|
|
({}, {"selected_map": ""}, ""),
|
|
({}, {}, "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_map_id_shared_fixture_ignores_vacuum_json_nonce():
|
|
fixture = json.loads((ROOT / "test" / "fixtures" / "vacuum-attrs" / "map-id.json").read_text(
|
|
encoding="utf-8"
|
|
))
|
|
for row in fixture:
|
|
assert trails.resolve_map_id(row["source"], row["vacuum"]) == row["expected"]
|
|
assert fixture[-2]["expected"] == fixture[-1]["expected"]
|
|
book = trails.TrailBook()
|
|
for index, row in enumerate(fixture[-2:]):
|
|
map_id = trails.resolve_map_id(row["source"], row["vacuum"])
|
|
book.on_point("m1", map_id, float(index), 0.0, float(index))
|
|
assert book.data["m1"]["current"]["points"] == [[0.0, 0.0], [1.0, 0.0]]
|
|
assert "previous" not in book.data["m1"]
|
|
|
|
|
|
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_source_health_deduplicates_reason_changes_and_warns_after_recovery(caplog):
|
|
rec, hass, states = _rec()
|
|
key = {("m1", "camera.map")}
|
|
|
|
class Entry:
|
|
def __init__(self, disabled_by=None): self.disabled_by = disabled_by
|
|
|
|
class Registry:
|
|
row = None
|
|
def async_get(self, _eid): return self.row
|
|
|
|
registry = Registry()
|
|
old_get = trails.er.async_get
|
|
trails.er.async_get = lambda _hass: registry
|
|
try:
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
rec._refresh_source_health(key) # available baseline
|
|
del states["camera.map"]
|
|
rec._refresh_source_health(key) # missing: warning 1
|
|
registry.row = Entry("user")
|
|
rec._refresh_source_health(key) # disabled: reason update, no warning
|
|
registry.row = None
|
|
rec._refresh_source_health(key) # missing again, no warning
|
|
states["camera.map"] = S("unavailable", {})
|
|
rec._refresh_source_health(key) # proven existence: recovery
|
|
del states["camera.map"]
|
|
rec._refresh_source_health(key) # missing after recovery: warning 2
|
|
warnings = [record for record in caplog.records if record.levelname == "WARNING"]
|
|
assert len(warnings) == 2
|
|
assert rec._source_health[("m1", "camera.map")] == "missing"
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_source_health_lifecycle_clears_removed_or_rebound_marker(caplog):
|
|
rec, _hass, states = _rec()
|
|
del states["camera.map"]
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
old_get = trails.er.async_get
|
|
trails.er.async_get = lambda _hass: type("Registry", (), {"async_get": lambda self, eid: None})()
|
|
try:
|
|
rec._refresh_source_health({("m1", "camera.map")})
|
|
assert rec._source_health
|
|
rec._refresh_source_health(set())
|
|
assert rec._source_health == {}
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_source_health_unavailable_and_unsupported_are_proven_recovery(caplog):
|
|
rec, _hass, states = _rec()
|
|
key = {("m1", "camera.map")}
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
old_get = trails.er.async_get
|
|
trails.er.async_get = lambda _hass: type("Registry", (), {"async_get": lambda self, eid: None})()
|
|
try:
|
|
del states["camera.map"]
|
|
rec._refresh_source_health(key)
|
|
assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 1
|
|
states["camera.map"] = S("unavailable", {})
|
|
rec._refresh_source_health(key)
|
|
assert rec._source_health == {}
|
|
del states["camera.map"]
|
|
rec._refresh_source_health(key)
|
|
states["camera.map"] = S("idle", {}) # exists, but no supported attrs
|
|
rec._refresh_source_health(key)
|
|
assert rec._source_health == {}
|
|
assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 2
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_source_health_startup_and_source_change_lifecycle(caplog):
|
|
rec, _hass, states = _rec()
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
old_get = trails.er.async_get
|
|
trails.er.async_get = lambda _hass: type("Registry", (), {"async_get": lambda self, eid: None})()
|
|
try:
|
|
del states["camera.map"]
|
|
rec._refresh_source_health({("m1", "camera.map")})
|
|
rec._refresh_source_health({("m1", "camera.other")})
|
|
assert ("m1", "camera.map") not in rec._source_health
|
|
assert rec._source_health[("m1", "camera.other")] == "missing"
|
|
assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 2
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_source_health_startup_existing_states_do_not_warn(caplog):
|
|
rec, _hass, states = _rec()
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
states["camera.map"] = S("unavailable", {})
|
|
rec._refresh_source_health({("m1", "camera.map")})
|
|
states["camera.map"] = S("idle", {})
|
|
rec._refresh_source_health({("m1", "camera.map")})
|
|
assert not [r for r in caplog.records if r.levelname == "WARNING"]
|
|
|
|
|
|
def test_source_health_disabled_unsupported_disabled_warns_twice(caplog):
|
|
rec, _hass, states = _rec()
|
|
key = {("m1", "camera.map")}
|
|
|
|
class Entry:
|
|
def __init__(self, disabled_by=None): self.disabled_by = disabled_by
|
|
|
|
class Registry:
|
|
row = Entry()
|
|
def async_get(self, _eid): return self.row
|
|
|
|
registry = Registry()
|
|
old_get = trails.er.async_get
|
|
trails.er.async_get = lambda _hass: registry
|
|
try:
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
states["camera.map"] = S("idle", {}) # unsupported, but existing
|
|
rec._refresh_source_health(key)
|
|
registry.row = Entry("user")
|
|
rec._refresh_source_health(key)
|
|
registry.row = Entry()
|
|
rec._refresh_source_health(key) # proven unsupported recovery
|
|
registry.row = Entry("user")
|
|
rec._refresh_source_health(key)
|
|
assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 2
|
|
assert rec._source_health[("m1", "camera.map")] == "disabled"
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_source_health_unverified_is_neutral(caplog):
|
|
rec, _hass, states = _rec()
|
|
key = {("m1", "camera.map")}
|
|
|
|
class Registry:
|
|
def async_get(self, _eid): return None
|
|
|
|
registry = Registry()
|
|
old_get = trails.er.async_get
|
|
try:
|
|
caplog.set_level("WARNING", logger=trails.__name__)
|
|
del states["camera.map"]
|
|
trails.er.async_get = lambda _hass: registry
|
|
rec._refresh_source_health(key) # missing: warning 1
|
|
trails.er.async_get = lambda _hass: None
|
|
rec._refresh_source_health(key) # unverified: neutral
|
|
trails.er.async_get = lambda _hass: registry
|
|
rec._refresh_source_health(key) # same incident, no warning
|
|
assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 1
|
|
assert rec._source_health[("m1", "camera.map")] == "missing"
|
|
finally:
|
|
trails.er.async_get = old_get
|
|
|
|
|
|
def test_refresh_never_tracks_a_removed_marker_even_with_stale_vacuum_fields():
|
|
class CS:
|
|
async def async_load(self):
|
|
return {"config": {"markers": [{
|
|
"id": "m1",
|
|
"binding": "entity:vacuum.x50",
|
|
"removed": True,
|
|
"vacuum": {"source": "camera.map"},
|
|
}]}}
|
|
|
|
class RT:
|
|
config_store = CS()
|
|
|
|
hass = Hass({
|
|
"vacuum.x50": S("cleaning", {}),
|
|
"camera.map": S("idle", {"vacuum_position": {"x": 1, "y": 2}}),
|
|
})
|
|
rec = trails.TrailRecorder(hass, RT())
|
|
_run_isolated(rec.async_refresh())
|
|
assert rec.pairs == {}
|
|
assert rec.book.data == {}
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_async_teardown_flushes_pending_debounced_state_and_closes_handles():
|
|
rec, _hass, _states = _rec()
|
|
saved = []
|
|
cancelled = []
|
|
untracked = []
|
|
|
|
class TrailStore:
|
|
async def async_save(self, data):
|
|
saved.append(json.loads(json.dumps(data)))
|
|
|
|
rec.store = TrailStore()
|
|
rec.book.data = {"m1": {"current": {"points": [[1, 2]]}}}
|
|
rec._unsub_save = lambda: cancelled.append(True)
|
|
rec._unsub_track = lambda: untracked.append(True)
|
|
|
|
_run_isolated(rec.async_teardown())
|
|
|
|
assert saved == [rec.book.data]
|
|
assert cancelled == [True]
|
|
assert untracked == [True]
|
|
assert rec._unsub_save is None and rec._unsub_track is None
|
|
assert rec._closed is True
|
|
|
|
|
|
def test_refresh_watches_every_route_source_not_only_the_root(monkeypatch):
|
|
"""#162: карты одного робота могут идти через разные камеры."""
|
|
import asyncio
|
|
|
|
tracked = []
|
|
old_track = trails.async_track_state_change_event
|
|
trails.async_track_state_change_event = lambda hass, ents, cb: (
|
|
tracked.append(sorted(ents)) or (lambda: None))
|
|
try:
|
|
markers = [{
|
|
"id": "m1", "binding": "entity:vacuum.x50", "space": "floor1",
|
|
"vacuum": {
|
|
"source": "camera.floor1",
|
|
"map_routes": [
|
|
{"id": "vr1", "source": "camera.floor1", "map_id": "a", "space": "floor1"},
|
|
{"id": "vr2", "source": "camera.floor2", "map_id": "b", "space": "floor2"},
|
|
],
|
|
},
|
|
}]
|
|
|
|
class CS:
|
|
async def async_load(self):
|
|
return {"config": {"markers": markers}}
|
|
|
|
class RT:
|
|
config_store = CS()
|
|
|
|
hass = Hass({
|
|
"vacuum.x50": S("docked", {}),
|
|
"camera.floor1": S("idle", {}),
|
|
"camera.floor2": S("idle", {}),
|
|
})
|
|
rec = trails.TrailRecorder(hass, RT())
|
|
_run_isolated(rec.async_refresh())
|
|
assert sorted(rec.pairs) == ["camera.floor1", "camera.floor2"]
|
|
assert rec.pairs["camera.floor2"] == [("m1", "vacuum.x50")]
|
|
assert tracked == [["camera.floor1", "camera.floor2", "vacuum.x50"]]
|
|
assert [r["id"] for r in rec.routes_by_marker["m1"]] == ["vr1", "vr2"]
|
|
finally:
|
|
trails.async_track_state_change_event = old_track
|
|
|
|
|
|
def test_sample_files_the_point_under_its_route():
|
|
states = {
|
|
"vacuum.x50": S("cleaning", {}),
|
|
"camera.floor2": S("idle", {"vacuum_position": {"x": 5, "y": 6}, "map_name": "b"}),
|
|
}
|
|
rec = trails.TrailRecorder(Hass(states), None)
|
|
rec.pairs = {"camera.floor2": [("m1", "vacuum.x50")]}
|
|
rec.routes_by_marker = {"m1": [
|
|
{"id": "vr1", "source": "camera.floor1", "map_id": "b", "space": "floor1"},
|
|
{"id": "vr2", "source": "camera.floor2", "map_id": "b", "space": "floor2"},
|
|
]}
|
|
rec._on_state(E("camera.floor2"))
|
|
run = rec.book.data["m1"]["current"]
|
|
assert run["route_id"] == "vr2", "источник, а не только id карты, выбирает маршрут"
|
|
assert run["source"] == "camera.floor2"
|
|
assert run["points"] == [[5.0, 6.0]]
|
|
|
|
|
|
def test_sample_without_a_matching_route_records_legacy_shaped_run():
|
|
states = {
|
|
"vacuum.x50": S("cleaning", {}),
|
|
"camera.map": S("idle", {"vacuum_position": {"x": 1, "y": 2}, "map_name": "неизвестная"}),
|
|
}
|
|
rec = trails.TrailRecorder(Hass(states), None)
|
|
rec.pairs = {"camera.map": [("m1", "vacuum.x50")]}
|
|
rec.routes_by_marker = {"m1": [
|
|
{"id": "vr1", "source": "camera.map", "map_id": "b", "space": "floor1"},
|
|
]}
|
|
rec._on_state(E("camera.map"))
|
|
run = rec.book.data["m1"]["current"]
|
|
assert "route_id" not in run, "чужой маршрут прогону не приписывается"
|
|
assert run["map_id"] == "неизвестная"
|
|
assert run["points"] == [[1.0, 2.0]]
|