From 56e01148f89699a40fb9ce22b0aecc8c6c3fc5e0 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 19 Aug 2026 20:41:07 +0300 Subject: [PATCH] fix: resume vacuum trails after short stops Issue: #205 User-Visible: yes --- custom_components/houseplan/trails.py | 34 +++++++++- demo/smoke_vacuum.mjs | 26 ++++++++ docs/CHANGELOG.md | 5 ++ docs/CHANGELOG.ru.md | 6 ++ docs/STATUS.md | 4 +- docs/TESTING.md | 10 +++ docs/USER-GUIDE.ru.md | 7 +++ docs/VACUUM.md | 11 +++- scripts/mutation-gate.mjs | 11 ++++ scripts/trail-resume-test-guard.mjs | 22 +++++++ tests_backend/test_trail_recorder.py | 43 +++++++++++++ tests_backend/test_trails.py | 89 +++++++++++++++++++++++++-- 12 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 scripts/trail-resume-test-guard.mjs diff --git a/custom_components/houseplan/trails.py b/custom_components/houseplan/trails.py index d040292a..ca17cd6f 100755 --- a/custom_components/houseplan/trails.py +++ b/custom_components/houseplan/trails.py @@ -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 diff --git a/demo/smoke_vacuum.mjs b/demo/smoke_vacuum.mjs index 1899c61f..56879fc4 100644 --- a/demo/smoke_vacuum.mjs +++ b/demo/smoke_vacuum.mjs @@ -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, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b9264671..a62a21b8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index bd53d02e..d5567675 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -14,6 +14,12 @@ изменения любого из них смена источника сохраняет всю выбранную пару; мастер этажей/зон следует тому же правилу ([#204](https://github.com/Matysh/houseplan-card/issues/204)). +- Если робот вернулся на базу, поставлен на паузу или ненадолго перешёл в + другое доступное неподвижное состояние, при возобновлении движения на той же + карте в течение 30 минут он продолжает прежний серверный след. Путь до мойки + швабр и похожих визитов на станцию больше не теряется; смена карты или более + долгая остановка по-прежнему начинает новую уборку + ([#205](https://github.com/Matysh/houseplan-card/issues/205)). - Выключение **«Показывать названия»** теперь полностью убирает названия комнат из Просмотра, киоска, статической карточки и скрытой изометрии, а редактор Плана по-прежнему временно показывает существующую карточку для настройки diff --git a/docs/STATUS.md b/docs/STATUS.md index a1c1fad5..dd644ace 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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/-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 -- --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 | diff --git a/docs/TESTING.md b/docs/TESTING.md index eea2a579..2a62e7f4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 296f4159..19cf826c 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1135,6 +1135,13 @@ Map Extractor, dreame-vacuum (Tasshack) и Valetudo-подобные камер ложной линией. Хранятся максимум 64 рисуемых участка и 4000 точек. - Для текущего пути приоритет таков: путь интеграции → серверный текущий run → локальный буфер. Пустой или одноточечный участок приоритет не перехватывает. +- Остановка, пауза или визит на базу сразу скрывают текущий след в режиме + «Во время уборки». Если робот снова движется на той же карте не позднее чем + через 30 минут, сервер продолжает тот же путь со всеми ранними точками. + Смена карты или более долгая остановка начинает новый путь. Недоступность + сущности сама по себе уборку не завершает. Поскольку не все интеграции отдают + ID задания, две отдельные уборки одной карты с паузой менее 30 минут могут + выглядеть как одна. ## 17. Киоск-режим diff --git a/docs/VACUUM.md b/docs/VACUUM.md index 772b13df..0f939145 100644 --- a/docs/VACUUM.md +++ b/docs/VACUUM.md @@ -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. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index e24b9e38..fef225a7 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', + }], + }, ]; // --- механика --------------------------------------------------------------- diff --git a/scripts/trail-resume-test-guard.mjs b/scripts/trail-resume-test-guard.mjs new file mode 100644 index 00000000..74433cf6 --- /dev/null +++ b/scripts/trail-resume-test-guard.mjs @@ -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); diff --git a/tests_backend/test_trail_recorder.py b/tests_backend/test_trail_recorder.py index 99f21cdf..73e28a3c 100644 --- a/tests_backend/test_trail_recorder.py +++ b/tests_backend/test_trail_recorder.py @@ -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): diff --git a/tests_backend/test_trails.py b/tests_backend/test_trails.py index 9d583b97..608ab996 100644 --- a/tests_backend/test_trails.py +++ b/tests_backend/test_trails.py @@ -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)