fix: resume vacuum trails after short stops

Issue: #205
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 20:55:38 +03:00
parent d31ad3c562
commit 56e01148f8
12 changed files with 258 additions and 10 deletions
+31 -3
View File
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import math
import time
from typing import Any
@@ -24,11 +25,33 @@ import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
"""Whether an ended current run may be reopened for this point.
Store timestamps are untrusted persisted data. Only finite JSON-number
timestamps and a non-negative inclusive grace interval are accepted;
malformed values and wall-clock rollback fail closed into a new run.
"""
if not isinstance(run, dict) or run.get("map_id") != map_id:
return False
ended = run.get("ended")
if (
isinstance(ended, bool)
or not isinstance(ended, (int, float))
or isinstance(now, bool)
or not isinstance(now, (int, float))
):
return False
elapsed = now - ended
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
@@ -64,7 +87,10 @@ class TrailBook:
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
if resumed:
cur["ended"] = None
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
@@ -73,7 +99,9 @@ class TrailBook:
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
# Clearing ended is observable state even if the source repeats
# the dock point: it must still reach Store and live cards.
return resumed
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
@@ -85,7 +113,7 @@ class TrailBook:
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
if cur and cur.get("ended") is None:
cur["ended"] = now
return True
return False
+26
View File
@@ -109,6 +109,31 @@ const out = await page.evaluate(async () => {
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
// ---- resumed server run: dock hides it, same current returns whole ----
// Backend owns the 30-minute decision. The card must neither paint an ended
// current in default mode nor lose its pre-dock points after ended is cleared.
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 10, ended: 20,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(950, 1150) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.srvEndedCurrentHiddenDefault = !sr().querySelector('.vactrail path, .vactrail polyline');
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 10, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150], [1200, 1200]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(1200, 1200) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
const resumedD = sr().querySelector('.vactrail > path.case')?.getAttribute('d') || '';
o.srvResumedCurrentKeepsEarlierPoints = (resumedD.match(/[ML]/g) || []).length === 4;
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
@@ -247,6 +272,7 @@ checkAll(out, {
trailCasing: true, trailToggleOff: true,
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvEndedCurrentHiddenDefault: true, srvResumedCurrentKeepsEarlierPoints: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
multiSubpathNoBridge: true, integrationPathAlwaysAtRest: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
+5
View File
@@ -7,6 +7,11 @@
plan visibly turns both on. After either switch is edited, changing the plan
source preserves the complete user-selected pair; the Floors/Areas wizard
follows the same rule ([#204](https://github.com/Matysh/houseplan-card/issues/204)).
- A robot that docks, pauses or briefly enters another available non-moving
state now resumes the same server trail when it moves again on the same map
within 30 minutes. Earlier points therefore survive mop washing and similar
station visits; a map change or a longer stop still starts a new run
([#205](https://github.com/Matysh/houseplan-card/issues/205)).
- Turning off **Show names** now removes room names completely in View, kiosk,
the static card and hidden isometric mode, while Plan still exposes the
existing draggable card for positioning. Re-enabling names restores its
+6
View File
@@ -14,6 +14,12 @@
изменения любого из них смена источника сохраняет всю выбранную пару; мастер
этажей/зон следует тому же правилу
([#204](https://github.com/Matysh/houseplan-card/issues/204)).
- Если робот вернулся на базу, поставлен на паузу или ненадолго перешёл в
другое доступное неподвижное состояние, при возобновлении движения на той же
карте в течение 30 минут он продолжает прежний серверный след. Путь до мойки
швабр и похожих визитов на станцию больше не теряется; смена карты или более
долгая остановка по-прежнему начинает новую уборку
([#205](https://github.com/Matysh/houseplan-card/issues/205)).
- Выключение **«Показывать названия»** теперь полностью убирает названия комнат
из Просмотра, киоска, статической карточки и скрытой изометрии, а редактор
Плана по-прежнему временно показывает существующую карточку для настройки
+2 -2
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.65.0-beta.5** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for the current `S8-merged` queue |
| Current local cycle | v1.65.0-beta.5 candidate carries #176, #186, #195, #200, #201 and #203; issue branch #204 additionally makes new-space File/Draw border/name defaults visible and preserves a user-touched pair across source switches. Independent-wall openings preserve their physical jamb margin, room labels keep View/Plan geometry and obey the name-visibility switch, atomic wall stretches inherit the intended thickness, the editor close target is easier to hit, and unreachable legacy Partition tool-state is removed. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; v1.65.0-beta.4 is published and stable v1.64.0 remains unchanged. |
| Current local cycle | v1.65.0-beta.5 candidate carries #176, #186, #195, #200, #201, #203 and #204; issue branch #205 additionally preserves one same-map vacuum trail across available stops up to 30 minutes. New-space File/Draw border/name defaults are visible and a user-touched pair survives source switches; independent-wall openings preserve their physical jamb margin, room labels keep View/Plan geometry and obey the name-visibility switch, atomic wall stretches inherit the intended thickness, the editor close target is easier to hit, and unreachable legacy Partition tool-state is removed. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; v1.65.0-beta.4 is published and stable v1.64.0 remains unchanged. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
@@ -32,7 +32,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: Node unit (`npm test`: frontend pure modules + tooling policy), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch. All three editors are desktop-first; touch editing is best effort and may be awkward, reduced or absent when parity is expensive. `docs/TOUCH-SUPPORT.md` defines the non-negotiable safety floor and documentation/test rules |
| Vacuums | Live puck, server-side trails and fit calibration are shipped. The local v1.61 Stage 1 contract in docs/VACUUM.md adds explicit Dreame/XCME/Valetudo coverage, registry-less source selection, capability diagnostics, path-gap preservation and source-health warnings. Roomba remains Stage 2 |
| Vacuums | Live puck, server-side trails and fit calibration are shipped. The local v1.61 Stage 1 contract in docs/VACUUM.md adds explicit Dreame/XCME/Valetudo coverage, registry-less source selection, capability diagnostics, path-gap preservation and source-health warnings; #205's issue branch resumes one ended same-map run through an inclusive 30-minute station/pause grace. Roomba remains Stage 2 |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | `docs/SCOPE.md` is the feature guard rail; `docs/TOUCH-SUPPORT.md` is the input-support contract — check both before accepting interaction work |
+10
View File
@@ -1534,6 +1534,16 @@ require hands on real hardware — they remain for the human pass.
runs kept per marker, survives reloads, shared by every screen) and never
outruns the icon: drawn segments lag one point, and the last segment is a
rAF-driven tip glued to the puck centre every frame.
- Same-map `cleaning → docked/paused/error → cleaning` resumes the ended
current through exactly 30:00 and retains every point plus any older
previous run; 30:00 + epsilon or a map change rotates as before. Repeated
stop samples do not extend the window; malformed timestamps and clock
rollback fail closed [backend: `test_trails.py`, `test_trail_recorder.py`].
- `unavailable`, `unknown` and a missing vacuum state are neutral. While
stopped, default mode hides the ended current; after backend resume the
production card paints the complete reopened current, while `always` keeps
its current/previous styling [auto: `smoke_vacuum`; mutation:
`vacuum-trail-resume-disabled`].
- Calling trail delete for a marker with no stored run is a true no-op: its
source/vacuum pair stays subscribed. A successful delete removes only that
marker's pairs and immediately rebuilds the subscription
+7
View File
@@ -1135,6 +1135,13 @@ Map Extractor, dreame-vacuum (Tasshack) и Valetudo-подобные камер
ложной линией. Хранятся максимум 64 рисуемых участка и 4000 точек.
- Для текущего пути приоритет таков: путь интеграции → серверный текущий run →
локальный буфер. Пустой или одноточечный участок приоритет не перехватывает.
- Остановка, пауза или визит на базу сразу скрывают текущий след в режиме
«Во время уборки». Если робот снова движется на той же карте не позднее чем
через 30 минут, сервер продолжает тот же путь со всеми ранними точками.
Смена карты или более долгая остановка начинает новый путь. Недоступность
сущности сама по себе уборку не завершает. Поскольку не все интеграции отдают
ID задания, две отдельные уборки одной карты с паузой менее 30 минут могут
выглядеть как одна.
## 17. Киоск-режим
+10 -1
View File
@@ -114,7 +114,16 @@ subpath survive deterministic proportional thinning.
Server trails are recorded by `custom_components/houseplan/trails.py`, even
with no card open. It stores current and one previous run in raw robot
coordinates. Server recording is independent of the display mode. The source
coordinates. An available non-moving state ends the visible current run
immediately, but a new point on the same map within an inclusive 30-minute
grace reopens that same run and keeps all earlier points. The first stop fixes
the timestamp: repeated dock/pause samples do not extend the window. A map
change, a longer stop, malformed persisted time or wall-clock rollback starts a
new run. `unavailable`, `unknown` and a missing state remain neutral. Without a
vendor task id, two genuinely separate same-map cleanups started inside the
grace may therefore appear as one run.
Server recording is independent of the display mode. The source
health monitor checks saved marker/source pairs on config refresh and restart:
one warning is emitted for a missing/disabled incident, reason changes are
deduplicated, and another warning is possible only after proven recovery.
+11
View File
@@ -488,6 +488,17 @@ export const MUTANTS = [
+ " show_names: d.source === 'draw' && d.mode === 'create' ? true : d.showNames,",
}],
},
{
id: 'vacuum-trail-resume-disabled',
guard: 'node scripts/trail-resume-test-guard.mjs',
because: 'an ended same-map run inside the accepted 30-minute window must reopen instead '
+ 'of rotating into previous; focused pure and recorder sequences must reject the old unconditional split',
patches: [{
file: 'custom_components/houseplan/trails.py',
find: ' resumed = bool(cur and can_resume_trail_run(cur, map_id, now))',
replace: ' resumed = False',
}],
},
];
// --- механика ---------------------------------------------------------------
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
const localPython = process.platform === 'win32'
? join(process.cwd(), '.venv', 'Scripts', 'python.exe')
: join(process.cwd(), '.venv', 'bin', 'python');
const python = process.env.PYTHON
|| (existsSync(localPython) ? localPython : (process.platform === 'win32' ? 'python' : 'python3'));
const result = spawnSync(python, [
'-m', 'pytest',
'tests_backend/test_trails.py',
'tests_backend/test_trail_recorder.py',
'-q', '-k', 'resume or short_available',
], { stdio: 'inherit' });
if (result.error) {
console.error(`trail resume guard could not start ${python}: ${result.error.message}`);
process.exit(2);
}
process.exit(result.status ?? 2);
+43
View File
@@ -195,6 +195,49 @@ def test_unavailable_vacuum_is_no_verdict():
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):
+85 -4
View File
@@ -1,4 +1,5 @@
"""TrailBook: the pure part of the server-side vacuum trails."""
import math
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan"))
import importlib.util
@@ -8,10 +9,11 @@ spec = importlib.util.spec_from_file_location(
)
# import only TrailBook without HA deps: read the source and exec the class
src = (pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py").read_text(encoding="utf-8")
ns = {"Any": object, "annotations": None}
ns = {"Any": object, "annotations": None, "math": math}
exec(src[src.index("TRAIL_CAP"):src.index("class TrailRecorder")], ns)
TrailBook = ns["TrailBook"]
TRAIL_CAP = ns["TRAIL_CAP"]
TRAIL_RESUME_GRACE_S = ns["TRAIL_RESUME_GRACE_S"]
def test_append_dedups_and_records():
@@ -30,18 +32,97 @@ def test_end_then_new_run_rotates_current_to_previous():
b.on_point("m", "0", 2, 2, 2.0)
assert b.end_run("m", 3.0)
assert not b.end_run("m", 4.0) # idempotent
b.on_point("m", "0", 9, 9, 5.0)
b.on_point("m", "0", 9, 9, 3.0 + TRAIL_RESUME_GRACE_S + 1)
rec = b.data["m"]
assert rec["previous"]["points"] == [[1, 1], [2, 2]]
assert rec["previous"]["ended"] == 3.0
assert rec["current"]["points"] == [[9, 9]]
# a third run forgets the first entirely — exactly two are kept
b.end_run("m", 6.0)
b.on_point("m", "0", 7, 7, 7.0)
b.end_run("m", 4000.0)
b.on_point("m", "0", 7, 7, 4000.0 + TRAIL_RESUME_GRACE_S + 1)
assert rec if rec is b.data["m"] else True
assert b.data["m"]["previous"]["points"] == [[9, 9]]
def test_ended_run_resumes_within_grace_and_duplicate_is_a_change():
b = TrailBook()
b.on_point("m", "floor", 1, 1, 100.0)
b.on_point("m", "floor", 2, 2, 110.0)
assert b.end_run("m", 120.0)
assert b.on_point("m", "floor", 2, 2, 120.0 + 600)
rec = b.data["m"]
assert rec["current"]["points"] == [[1, 1], [2, 2]]
assert rec["current"]["started"] == 100.0
assert rec["current"]["ended"] is None
assert "previous" not in rec
def test_resume_grace_is_inclusive_then_rotates_after_epsilon():
b = TrailBook()
b.on_point("m", "floor", 1, 1, 10.0)
b.end_run("m", 20.0)
assert b.on_point("m", "floor", 2, 2, 20.0 + TRAIL_RESUME_GRACE_S)
assert "previous" not in b.data["m"]
b.end_run("m", 2000.0)
assert b.on_point("m", "floor", 3, 3, 2000.0 + TRAIL_RESUME_GRACE_S + 0.001)
assert b.data["m"]["previous"]["points"] == [[1, 1], [2, 2]]
assert b.data["m"]["current"]["points"] == [[3, 3]]
def test_resume_never_crosses_maps_and_malformed_timestamps_fail_closed():
cases = ["bad", True, float("nan"), float("inf")]
for ended in cases:
data = {"m": {"current": {
"map_id": "floor", "started": 1.0, "ended": ended, "points": [[1, 1]],
}}}
b = TrailBook(data)
assert b.on_point("m", "floor", 2, 2, 100.0)
assert b.data["m"]["previous"]["ended"] is ended or (
isinstance(ended, float) and math.isnan(ended)
)
assert b.data["m"]["current"]["points"] == [[2, 2]]
b = TrailBook()
b.on_point("m", "floor-a", 1, 1, 1.0)
b.end_run("m", 2.0)
b.on_point("m", "floor-b", 2, 2, 3.0)
assert b.data["m"]["previous"]["map_id"] == "floor-a"
assert b.data["m"]["current"]["map_id"] == "floor-b"
def test_resume_fails_closed_on_clock_rollback_and_survives_restart_shape():
stored = {"m": {
"previous": {"map_id": "floor", "started": 1.0, "ended": 2.0, "points": [[0, 0]]},
"current": {"map_id": "floor", "started": 10.0, "ended": 1000.0, "points": [[1, 1]]},
}}
b = TrailBook(stored)
b.on_point("m", "floor", 2, 2, 900.0)
assert b.data["m"]["previous"]["points"] == [[1, 1]]
assert b.data["m"]["current"]["points"] == [[2, 2]]
persisted = {"m": {"current": {
"map_id": "floor", "started": 2000.0, "ended": 2100.0, "points": [[3, 3]],
}}}
restarted = TrailBook(persisted)
restarted.on_point("m", "floor", 4, 4, 2100.0 + 300)
assert restarted.data["m"]["current"]["points"] == [[3, 3], [4, 4]]
assert restarted.data["m"]["current"]["started"] == 2000.0
assert restarted.data["m"]["current"]["ended"] is None
assert "previous" not in restarted.data["m"]
def test_repeated_short_stops_keep_all_points_and_existing_previous():
previous = {"map_id": "floor", "started": 0.0, "ended": 5.0, "points": [[0, 0]]}
b = TrailBook({"m": {"previous": previous}})
for index, now in enumerate((100.0, 200.0, 300.0)):
assert b.on_point("m", "floor", index + 1, index + 1, now)
assert b.end_run("m", now + 10)
assert not b.end_run("m", now + 20)
assert b.data["m"]["current"]["points"] == [[1, 1], [2, 2], [3, 3]]
assert b.data["m"]["previous"] is previous
def test_map_switch_mid_run_starts_a_new_run():
b = TrailBook()
b.on_point("m", "floor1", 1, 1, 1.0)