mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: resume vacuum trails after short stops
Issue: #205 User-Visible: yes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1135,6 +1135,13 @@ Map Extractor, dreame-vacuum (Tasshack) и Valetudo-подобные камер
|
||||
ложной линией. Хранятся максимум 64 рисуемых участка и 4000 точек.
|
||||
- Для текущего пути приоритет таков: путь интеграции → серверный текущий run →
|
||||
локальный буфер. Пустой или одноточечный участок приоритет не перехватывает.
|
||||
- Остановка, пауза или визит на базу сразу скрывают текущий след в режиме
|
||||
«Во время уборки». Если робот снова движется на той же карте не позднее чем
|
||||
через 30 минут, сервер продолжает тот же путь со всеми ранними точками.
|
||||
Смена карты или более долгая остановка начинает новый путь. Недоступность
|
||||
сущности сама по себе уборку не завершает. Поскольку не все интеграции отдают
|
||||
ID задания, две отдельные уборки одной карты с паузой менее 30 минут могут
|
||||
выглядеть как одна.
|
||||
|
||||
## 17. Киоск-режим
|
||||
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user