feat: record vacuum trails under the route that produced them

User-Visible: no
Issue: #162
This commit is contained in:
Codex
2026-09-03 19:18:13 +03:00
parent c38501ef50
commit fec99b907b
4 changed files with 277 additions and 12 deletions
+77
View File
@@ -729,3 +729,80 @@ def test_overlapping_refreshes_leave_one_subscription_teardown_zero():
_run_isolated(scenario())
finally:
trails.async_track_state_change_event = old_track
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]]
+71
View File
@@ -143,3 +143,74 @@ def test_junk_store_data_tolerated():
assert b.data == {}
b.on_point("m", "0", 1, 1, 1.0)
assert b.data["m"]["current"]["points"] == [[1, 1]]
# --- #162: маршруты карт и пространств ---------------------------------------
def test_route_change_starts_a_new_run_even_on_the_same_map_id():
b = TrailBook()
b.on_point("m", "default", 1.0, 1.0, 100.0, route_id="vr_a", source="camera.a")
b.on_point("m", "default", 2.0, 2.0, 101.0, route_id="vr_b", source="camera.b")
rec = b.data["m"]
assert rec["previous"]["route_id"] == "vr_a"
assert rec["current"]["route_id"] == "vr_b"
assert rec["current"]["source"] == "camera.b"
assert rec["current"]["points"] == [[2.0, 2.0]]
def test_same_route_keeps_one_run():
b = TrailBook()
b.on_point("m", "default", 1.0, 1.0, 100.0, route_id="vr_a", source="camera.a")
b.on_point("m", "default", 2.0, 2.0, 101.0, route_id="vr_a", source="camera.a")
assert "previous" not in b.data["m"]
assert b.data["m"]["current"]["points"] == [[1.0, 1.0], [2.0, 2.0]]
def test_run_without_route_stays_legacy_shaped():
b = TrailBook()
b.on_point("m", "0", 1.0, 2.0, 100.0)
run = b.data["m"]["current"]
assert "route_id" not in run and "source" not in run
assert set(run) == {"map_id", "started", "ended", "points"}
def test_legacy_run_resumes_by_map_id_as_before():
b = TrailBook()
b.on_point("m", "0", 1.0, 2.0, 100.0)
b.end_run("m", 200.0)
assert b.on_point("m", "0", 3.0, 4.0, 300.0)
assert "previous" not in b.data["m"]
assert b.data["m"]["current"]["points"] == [[1.0, 2.0], [3.0, 4.0]]
def test_route_run_does_not_resume_into_another_route():
b = TrailBook()
b.on_point("m", "default", 1.0, 2.0, 100.0, route_id="vr_a")
b.end_run("m", 200.0)
b.on_point("m", "default", 3.0, 4.0, 300.0, route_id="vr_b")
assert b.data["m"]["previous"]["route_id"] == "vr_a"
assert b.data["m"]["current"]["route_id"] == "vr_b"
def test_drop_unknown_routes_touches_only_runs_that_name_a_route():
b = TrailBook()
b.on_point("m", "m1", 1.0, 1.0, 100.0, route_id="vr_gone")
b.on_point("m", "m2", 2.0, 2.0, 101.0, route_id="vr_live")
assert b.drop_unknown_routes("m", {"vr_live"}) is True
assert "previous" not in b.data["m"]
assert b.data["m"]["current"]["route_id"] == "vr_live"
assert b.drop_unknown_routes("m", {"vr_live"}) is False
legacy = TrailBook()
legacy.on_point("m", "m1", 1.0, 1.0, 100.0)
assert legacy.drop_unknown_routes("m", set()) is False, "легаси-прогон не трогаем"
assert legacy.data["m"]["current"]["points"] == [[1.0, 1.0]]
assert TrailBook().drop_unknown_routes("нет такого", {"vr"}) is False
def test_same_run_identity_contract():
same = ns["same_run_identity"]
assert same({"route_id": "vr_a", "map_id": "x"}, "vr_a", "y") is True
assert same({"route_id": "vr_a", "map_id": "x"}, "vr_b", "x") is False
assert same({"map_id": "x"}, "vr_a", "x") is True, "легаси-прогон опознаётся картой"
assert same({"map_id": "x"}, "", "y") is False
assert same(None, "vr", "x") is False