From 9126e5b4301eb87db5e37c46768791d044e78b73 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 3 Sep 2026 18:38:57 +0300 Subject: [PATCH] feat: pure map-to-space routing contracts for multi-floor vacuums User-Visible: no Issue: #162 --- custom_components/houseplan/vacuum_routes.py | 212 ++++++++++++++++ scripts/mutation-gate.mjs | 60 +++++ src/vacuum-routes.ts | 252 +++++++++++++++++++ test/fixtures/vacuum-routes/legacy-run.json | 67 +++++ test/fixtures/vacuum-routes/resolve.json | 113 +++++++++ test/vacuum-routes.test.mjs | 137 ++++++++++ tests_backend/test_vacuum_routes.py | 103 ++++++++ tsconfig.test.json | 2 +- 8 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 custom_components/houseplan/vacuum_routes.py create mode 100644 src/vacuum-routes.ts create mode 100644 test/fixtures/vacuum-routes/legacy-run.json create mode 100644 test/fixtures/vacuum-routes/resolve.json create mode 100644 test/vacuum-routes.test.mjs create mode 100644 tests_backend/test_vacuum_routes.py diff --git a/custom_components/houseplan/vacuum_routes.py b/custom_components/houseplan/vacuum_routes.py new file mode 100644 index 00000000..64009a41 --- /dev/null +++ b/custom_components/houseplan/vacuum_routes.py @@ -0,0 +1,212 @@ +"""Map-to-space routing for multi-floor vacuums (#162). + +Python mirror of ``src/vacuum-routes.ts``. The recorder and the import/export +flows must answer "which floor is this map on?" exactly the way the card does, +so both sides are driven by the same shared fixtures in +``test/fixtures/vacuum-routes/``. Keep the two files in step: a divergence here +shows up as a robot drawn on the wrong floor, which is worse than no robot. +""" +from __future__ import annotations + +import re +from typing import Any + +VAC_ROUTE_LIMIT = 32 +VAC_ROUTE_ID_MAX = 128 +VAC_ROUTE_SOURCE_MAX = 255 +VAC_ROUTE_MAP_ID_MAX = 255 +VAC_ROUTE_ERROR = "invalid_vacuum_map_route" + +_ENTITY_ID = re.compile(r"^[a-z_]+\.[a-zA-Z0-9_]+$") + + +def normalize_route_matrix(raw: Any) -> list[float] | None: + """Six finite numbers, or nothing; a five-number matrix is not almost fine.""" + if not isinstance(raw, (list, tuple)) or len(raw) != 6: + return None + out: list[float] = [] + for value in raw: + # bool is an int in Python; a boolean is not a coordinate. + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + number = float(value) + if number != number or number in (float("inf"), float("-inf")): + return None + out.append(number) + return out + + +def is_entity_id_like(value: Any) -> bool: + return isinstance(value, str) and bool(_ENTITY_ID.match(value)) + + +def validate_marker_routes( + marker_id: str, routes: Any, space_ids: set[str] | None, +) -> list[dict[str, str]]: + """Shape/uniqueness/reference issues for one marker's routes. + + ``space_ids`` None means the spaces are not known in this context (import + preview), so the referential check is skipped instead of failing every + route. + """ + if routes is None: + return [] + + def issue(route_id: str, reason: str) -> dict[str, str]: + return { + "code": VAC_ROUTE_ERROR, "markerId": marker_id, + "routeId": route_id, "reason": reason, + } + + if not isinstance(routes, list): + return [issue("", "not_object")] + problems: list[dict[str, str]] = [] + if len(routes) > VAC_ROUTE_LIMIT: + problems.append(issue("", "limit")) + seen_ids: set[str] = set() + seen_identity: set[str] = set() + for raw in routes: + if not isinstance(raw, dict): + problems.append(issue("", "not_object")) + continue + route_id = raw.get("id") if isinstance(raw.get("id"), str) else "" + if not route_id or len(route_id) > VAC_ROUTE_ID_MAX: + problems.append(issue(route_id or "", "id")) + continue + if route_id in seen_ids: + problems.append(issue(route_id, "duplicate_id")) + seen_ids.add(route_id) + source = raw.get("source") + if not is_entity_id_like(source) or len(source) > VAC_ROUTE_SOURCE_MAX: + problems.append(issue(route_id, "source")) + map_id = raw.get("map_id") + # An empty map id is a real id (resolve_map_id): type and length only. + if not isinstance(map_id, str) or len(map_id) > VAC_ROUTE_MAP_ID_MAX: + problems.append(issue(route_id, "map_id")) + space = raw.get("space") + if not isinstance(space, str) or not space: + problems.append(issue(route_id, "space")) + elif space_ids is not None and space not in space_ids: + problems.append(issue(route_id, "unknown_space")) + calibration = raw.get("calibration") + if calibration is not None and normalize_route_matrix(calibration) is None: + problems.append(issue(route_id, "calibration")) + if isinstance(source, str) and isinstance(map_id, str): + identity = source + " " + map_id + if identity in seen_identity: + problems.append(issue(route_id, "duplicate_identity")) + seen_identity.add(identity) + return problems + + +def legacy_route_id(marker_id: str, source: str, map_id: str) -> str: + return "legacy:" + marker_id + " " + source + " " + map_id + + +def effective_routes( + marker_id: str, vacuum: Any, dock_space: str, discovered_source: str | None = None, +) -> list[dict[str, Any]]: + """The routes a marker effectively has, legacy calibration included.""" + if not isinstance(vacuum, dict): + return [] + explicit = vacuum.get("map_routes") + if isinstance(explicit, list) and explicit: + out = [] + for route in explicit: + if not isinstance(route, dict) or not isinstance(route.get("id"), str): + continue + if not route["id"]: + continue + out.append({ + "id": route["id"], + "source": str(route.get("source") or ""), + "map_id": str(route.get("map_id") or ""), + "space": str(route.get("space") or ""), + "calibration": normalize_route_matrix(route.get("calibration")), + }) + return out + source = vacuum.get("source") + if not isinstance(source, str) or not source: + source = discovered_source or "" + if not source: + return [] + calibration = vacuum.get("calibration") + if not isinstance(calibration, dict): + return [] + out = [] + for map_id, matrix in calibration.items(): + normalized = normalize_route_matrix(matrix) + if normalized is None: + continue + out.append({ + "id": legacy_route_id(marker_id, source, str(map_id)), + "source": source, "map_id": str(map_id), "space": dock_space, + "calibration": normalized, + }) + return out + + +def resolve_route( + routes: list[dict[str, Any]], observed: dict[str, str], space_ids: set[str] | None, +) -> dict[str, Any]: + """Pick the one route the robot is on right now — or refuse to pick. + + List order must never decide a floor: two plausible routes are ambiguous, + not "the first one". + """ + matched: list[dict[str, Any]] = [] + saw_telemetry = False + fallback_source = "" + fallback_map_id = "" + for route in routes: + source = route.get("source") + if source not in observed: + continue + seen = observed[source] + saw_telemetry = True + if not fallback_source: + fallback_source = source + fallback_map_id = seen + if seen == route.get("map_id"): + matched.append(route) + if len(matched) > 1: + return {"kind": "ambiguous", "routeIds": sorted(str(r.get("id")) for r in matched)} + if len(matched) == 1: + route = matched[0] + if space_ids is not None and route.get("space") not in space_ids: + return {"kind": "missing_space", "route": route} + matrix = normalize_route_matrix(route.get("calibration")) + if matrix is None: + return {"kind": "needs_calibration", "route": route} + return {"kind": "ready", "route": {**route, "calibration": matrix}} + if saw_telemetry: + return {"kind": "unmapped", "source": fallback_source, "mapId": fallback_map_id} + if observed: + source = sorted(observed)[0] + return {"kind": "unmapped", "source": source, "mapId": observed[source]} + return {"kind": "none"} + + +def adopt_legacy_run( + run: Any, routes: list[dict[str, Any]], root_source: str | None, +) -> dict[str, Any]: + """Where a run recorded before #162 belongs (spec 11.3.1). + + Such a run stores only ``{map_id, started, ended, points}``: it carries no + source at all. The one surviving witness of the subscription that wrote it + is the marker's root ``vacuum.source``, so that is what narrows the + candidates; without it only the map id is compared. Two candidates mean the + run is not drawn — not that the first one wins. + """ + if not isinstance(run, dict) or not isinstance(run.get("map_id"), str): + return {"kind": "orphan_run"} + map_id = run["map_id"] + root = root_source if isinstance(root_source, str) else "" + candidates = [route for route in routes if route.get("map_id") == map_id] + if root: + candidates = [route for route in candidates if route.get("source") == root] + if len(candidates) == 1: + return {"kind": "adopted", "route": candidates[0]} + if not candidates: + return {"kind": "orphan_run"} + return {"kind": "ambiguous_run", "routeIds": sorted(str(r.get("id")) for r in candidates)} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 4958ffdd..ea9b4336 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -231,6 +231,66 @@ const MUTANT_DEFINITIONS = [ replace: ' role="dialog"', }], }, + { + id: 'vacuum-route-ambiguity-takes-the-first', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'two plausible routes must give ambiguous, never "the first one": a guessed ' + + 'floor turns the plan into a false statement about where the robot is (#162, M-A)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: ' if (matched.length > 1) {\n return { kind: \'ambiguous\', routeIds: matched.map((route) => route.id).sort() };\n }', + replace: ' if (matched.length > 1) {\n return { kind: \'ready\', route: matched[0] };\n }', + }], + }, + { + id: 'vacuum-route-missing-space-falls-back-to-dock', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'a route pointing at a deleted space must fail visibly, not quietly render the ' + + 'robot in the dock space it no longer belongs to (#162, M-B)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: " if (input.spaceIds && !input.spaceIds.has(route.space)) return { kind: 'missing_space', route };", + replace: ' // mutant: missing space ignored', + }], + }, + { + id: 'vacuum-route-unmapped-draws-anyway', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'an unmapped active map must draw nothing: reusing the last matrix puts the ' + + 'robot on a floor nobody mapped it to (#162, M-C)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: ' if (observed === route.map_id) matched.push(route);', + replace: ' matched.push(route);', + }], + }, + { + id: 'vacuum-route-identity-duplicates-allowed', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'two routes with the same (source, map_id) make the resolver permanently ' + + 'ambiguous and silently share one calibration (#162, M-D)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: " if (seenIdentity.has(identity)) problems.push(issue(markerId, id, 'duplicate_identity'));", + replace: ' // mutant: duplicate identity accepted', + }], + }, + { + id: 'vacuum-legacy-run-adopts-the-first-candidate', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'a legacy run carries no source, so two candidates must be ambiguous_run and ' + + 'draw nowhere; picking the first one invents a floor for old data (#162, M-H)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: " if (candidates.length === 1) return { kind: 'adopted', route: candidates[0] };", + replace: " if (candidates.length >= 1) return { kind: 'adopted', route: candidates[0] };", + }], + }, { id: 'area-snapshot-cleanup-ignores-authority', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/src/vacuum-routes.ts b/src/vacuum-routes.ts new file mode 100644 index 00000000..f09c241e --- /dev/null +++ b/src/vacuum-routes.ts @@ -0,0 +1,252 @@ +/** + * Multi-floor vacuum: which map belongs to which space (#162). + * + * Until now one robot meant one space: the dock's `marker.space` decided both + * where the dock icon sits and the only space where the overlay could ever be + * rendered, while `marker.vacuum.calibration` was keyed by map id alone. A + * robot with two maps therefore had nowhere to put the second floor. + * + * This module owns the whole map->space authority as pure functions, so the + * card, the editor and the backend recorder all read the same rules instead of + * each guessing a floor. Nothing here touches the DOM, `hass` or storage. + */ + +export type Affine = [number, number, number, number, number, number]; + +/** One saved answer to "this exact map of this exact source lives here". */ +export interface VacuumMapRoute { + id: string; + source: string; + map_id: string; + space: string; + calibration?: Affine | null; +} + +export interface VacuumRouteMarkerCfg { + source?: string | null; + calibration?: Record | null; + map_routes?: VacuumMapRoute[] | null; +} + +export const VAC_ROUTE_LIMIT = 32; +export const VAC_ROUTE_ID_MAX = 128; +export const VAC_ROUTE_SOURCE_MAX = 255; +export const VAC_ROUTE_MAP_ID_MAX = 255; +export const VAC_ROUTE_ERROR = 'invalid_vacuum_map_route'; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** Six finite numbers, or nothing. A five-number matrix is not "almost fine". */ +export function normalizeRouteMatrix(raw: unknown): Affine | null { + if (!Array.isArray(raw) || raw.length !== 6 || !raw.every(isFiniteNumber)) return null; + return [raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]] as Affine; +} + +/** `domain.object_id` — the same shape the source picker already accepts. */ +export function isEntityIdLike(value: unknown): value is string { + return typeof value === 'string' && /^[a-z_]+\.[a-zA-Z0-9_]+$/.test(value); +} + +export interface RouteValidationIssue { + code: typeof VAC_ROUTE_ERROR; + markerId: string; + routeId: string; + reason: + | 'not_object' | 'id' | 'duplicate_id' | 'source' | 'map_id' | 'space' + | 'unknown_space' | 'duplicate_identity' | 'calibration' | 'limit'; +} + +const issue = ( + markerId: string, routeId: string, reason: RouteValidationIssue['reason'], +): RouteValidationIssue => ({ code: VAC_ROUTE_ERROR, markerId, routeId, reason }); + +/** + * Shape/uniqueness/reference checks for one marker's routes. + * + * `spaceIds` null means "spaces are not known here" — the referential check is + * then skipped instead of failing every route, because an import preview runs + * before the target spaces exist. + */ +export function validateMarkerRoutes( + markerId: string, routes: unknown, spaceIds: Set | null, +): RouteValidationIssue[] { + if (routes == null) return []; + if (!Array.isArray(routes)) return [issue(markerId, '', 'not_object')]; + const problems: RouteValidationIssue[] = []; + if (routes.length > VAC_ROUTE_LIMIT) problems.push(issue(markerId, '', 'limit')); + const seenIds = new Set(); + const seenIdentity = new Set(); + for (const raw of routes) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + problems.push(issue(markerId, '', 'not_object')); + continue; + } + const route = raw as Record; + const id = typeof route.id === 'string' ? route.id : ''; + if (!id || id.length > VAC_ROUTE_ID_MAX) { problems.push(issue(markerId, id, 'id')); continue; } + if (seenIds.has(id)) problems.push(issue(markerId, id, 'duplicate_id')); + seenIds.add(id); + if (!isEntityIdLike(route.source) || (route.source as string).length > VAC_ROUTE_SOURCE_MAX) { + problems.push(issue(markerId, id, 'source')); + } + // An empty map id is a real id (see vacMapIdFromAttrs): only the type and + // the length are checked here. + if (typeof route.map_id !== 'string' || route.map_id.length > VAC_ROUTE_MAP_ID_MAX) { + problems.push(issue(markerId, id, 'map_id')); + } + if (typeof route.space !== 'string' || !route.space) { + problems.push(issue(markerId, id, 'space')); + } else if (spaceIds && !spaceIds.has(route.space)) { + problems.push(issue(markerId, id, 'unknown_space')); + } + if (route.calibration != null && normalizeRouteMatrix(route.calibration) === null) { + problems.push(issue(markerId, id, 'calibration')); + } + if (typeof route.source === 'string' && typeof route.map_id === 'string') { + const identity = route.source + ' ' + route.map_id; + if (seenIdentity.has(identity)) problems.push(issue(markerId, id, 'duplicate_identity')); + seenIdentity.add(identity); + } + } + return problems; +} + +/** Deterministic id for a route that exists only as legacy calibration data. */ +export function legacyRouteId(markerId: string, source: string, mapId: string): string { + return 'legacy:' + markerId + ' ' + source + ' ' + mapId; +} + +/** + * The routes a marker effectively has right now. + * + * Legacy configs are read, never rewritten: every `calibration[map_id]` shows + * up as a route into the dock space, so a plan that predates #162 keeps + * rendering byte for byte until the user edits routing explicitly. + */ +export function effectiveRoutes( + markerId: string, + marker: VacuumRouteMarkerCfg | null | undefined, + dockSpace: string, + discoveredSource?: string | null, +): VacuumMapRoute[] { + const explicit = marker?.map_routes; + if (Array.isArray(explicit) && explicit.length) { + return explicit + .filter((route) => route && typeof route.id === 'string' && route.id) + .map((route) => ({ + id: route.id, + source: String(route.source ?? ''), + map_id: String(route.map_id ?? ''), + space: String(route.space ?? ''), + calibration: normalizeRouteMatrix(route.calibration), + })); + } + const source = (typeof marker?.source === 'string' && marker.source) + ? marker.source + : (discoveredSource || ''); + if (!source) return []; + const calibration = marker?.calibration; + if (!calibration || typeof calibration !== 'object') return []; + const out: VacuumMapRoute[] = []; + for (const mapId of Object.keys(calibration)) { + const matrix = normalizeRouteMatrix(calibration[mapId]); + if (!matrix) continue; + out.push({ + id: legacyRouteId(markerId, source, mapId), + source, map_id: mapId, space: dockSpace, calibration: matrix, + }); + } + return out; +} + +export type VacuumRouteResolution = + | { kind: 'ready'; route: VacuumMapRoute } + | { kind: 'needs_calibration'; route: VacuumMapRoute } + | { kind: 'unmapped'; source: string; mapId: string } + | { kind: 'ambiguous'; routeIds: string[] } + | { kind: 'missing_space'; route: VacuumMapRoute } + | { kind: 'none' }; + +export interface RouteResolveInput { + routes: VacuumMapRoute[]; + /** Observed map id per source, exactly as the map-id contract computes it. */ + observed: Map | Record; + spaceIds: Set | null; +} + +const observedOf = ( + observed: RouteResolveInput['observed'], source: string, +): string | undefined => ( + observed instanceof Map ? observed.get(source) : observed?.[source] +); + +/** + * Pick the one route the robot is on right now — or refuse to pick. + * + * Order of the route list must never decide a floor: two plausible routes are + * `ambiguous`, not "the first one". A guessed floor is worse than no robot, + * because the plan stops being a statement of fact. + */ +export function resolveRoute(input: RouteResolveInput): VacuumRouteResolution { + const matched: VacuumMapRoute[] = []; + let sawTelemetry = false; + let fallbackSource = ''; + let fallbackMapId = ''; + for (const route of input.routes) { + const observed = observedOf(input.observed, route.source); + if (observed === undefined) continue; + sawTelemetry = true; + if (!fallbackSource) { fallbackSource = route.source; fallbackMapId = observed; } + if (observed === route.map_id) matched.push(route); + } + if (matched.length > 1) { + return { kind: 'ambiguous', routeIds: matched.map((route) => route.id).sort() }; + } + if (matched.length === 1) { + const route = matched[0]; + if (input.spaceIds && !input.spaceIds.has(route.space)) return { kind: 'missing_space', route }; + const matrix = normalizeRouteMatrix(route.calibration); + return matrix ? { kind: 'ready', route: { ...route, calibration: matrix } } + : { kind: 'needs_calibration', route }; + } + if (sawTelemetry) return { kind: 'unmapped', source: fallbackSource, mapId: fallbackMapId }; + const entries = input.observed instanceof Map + ? [...input.observed.entries()] + : Object.entries(input.observed || {}); + if (entries.length) { + const pick = entries.slice().sort((a, b) => a[0].localeCompare(b[0]))[0]; + return { kind: 'unmapped', source: pick[0], mapId: pick[1] }; + } + return { kind: 'none' }; +} + +export type LegacyRunAdoption = + | { kind: 'adopted'; route: VacuumMapRoute } + | { kind: 'orphan_run' } + | { kind: 'ambiguous_run'; routeIds: string[] }; + +/** + * Where a run recorded before #162 belongs (spec 11.3.1). + * + * Such a run stores only `{map_id, started, ended, points}` — it carries no + * source at all. The one surviving witness of the subscription that wrote it + * is the marker's root `vacuum.source`, so that is what narrows the + * candidates; when the root source is gone, there is no second witness and + * only the map id is compared. Two candidates mean the run is not drawn, not + * that the first one wins. + */ +export function adoptLegacyRun( + run: { map_id?: unknown; route_id?: unknown } | null | undefined, + routes: VacuumMapRoute[], + rootSource: string | null | undefined, +): LegacyRunAdoption { + if (!run || typeof run.map_id !== 'string') return { kind: 'orphan_run' }; + const mapId = run.map_id; + const root = typeof rootSource === 'string' ? rootSource : ''; + let candidates = routes.filter((route) => route.map_id === mapId); + if (root) candidates = candidates.filter((route) => route.source === root); + if (candidates.length === 1) return { kind: 'adopted', route: candidates[0] }; + if (candidates.length === 0) return { kind: 'orphan_run' }; + return { kind: 'ambiguous_run', routeIds: candidates.map((route) => route.id).sort() }; +} diff --git a/test/fixtures/vacuum-routes/legacy-run.json b/test/fixtures/vacuum-routes/legacy-run.json new file mode 100644 index 00000000..2a11087c --- /dev/null +++ b/test/fixtures/vacuum-routes/legacy-run.json @@ -0,0 +1,67 @@ +[ + { + "name": "единственный кандидат по map id", + "run": { "map_id": "m1" }, + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1" }, + { "id": "r2", "source": "camera.robot", "map_id": "m2", "space": "floor2" } + ], + "root_source": "camera.robot", + "expected": { "kind": "adopted", "route_id": "r1" } + }, + { + "name": "корневой источник отсеивает чужую камеру", + "run": { "map_id": "default" }, + "routes": [ + { "id": "r1", "source": "camera.old", "map_id": "default", "space": "floor1" }, + { "id": "r2", "source": "camera.new", "map_id": "default", "space": "floor2" } + ], + "root_source": "camera.old", + "expected": { "kind": "adopted", "route_id": "r1" } + }, + { + "name": "без корневого источника два одинаковых map id неразрешимы", + "run": { "map_id": "default" }, + "routes": [ + { "id": "r1", "source": "camera.old", "map_id": "default", "space": "floor1" }, + { "id": "r2", "source": "camera.new", "map_id": "default", "space": "floor2" } + ], + "root_source": "", + "expected": { "kind": "ambiguous_run", "route_ids": ["r1", "r2"] } + }, + { + "name": "корневой источник не совпал ни с одним маршрутом", + "run": { "map_id": "m1" }, + "routes": [ + { "id": "r1", "source": "camera.new", "map_id": "m1", "space": "floor1" } + ], + "root_source": "camera.old", + "expected": { "kind": "orphan_run" } + }, + { + "name": "карты такой нет ни в одном маршруте", + "run": { "map_id": "m7" }, + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1" } + ], + "root_source": "camera.robot", + "expected": { "kind": "orphan_run" } + }, + { + "name": "нулевой map id сопоставляется строго", + "run": { "map_id": "0" }, + "routes": [ + { "id": "r0", "source": "camera.robot", "map_id": "0", "space": "floor1" }, + { "id": "rd", "source": "camera.robot", "map_id": "default", "space": "floor2" } + ], + "root_source": "camera.robot", + "expected": { "kind": "adopted", "route_id": "r0" } + }, + { + "name": "маршрутов нет — run осиротел", + "run": { "map_id": "m1" }, + "routes": [], + "root_source": "camera.robot", + "expected": { "kind": "orphan_run" } + } +] diff --git a/test/fixtures/vacuum-routes/resolve.json b/test/fixtures/vacuum-routes/resolve.json new file mode 100644 index 00000000..6715297a --- /dev/null +++ b/test/fixtures/vacuum-routes/resolve.json @@ -0,0 +1,113 @@ +[ + { + "name": "один динамический источник: активна вторая карта", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] }, + { "id": "r2", "source": "camera.robot", "map_id": "m2", "space": "floor2", "calibration": [2, 0, 0, 0, 2, 0] } + ], + "observed": { "camera.robot": "m2" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ready", "route_id": "r2", "space": "floor2" } + }, + { + "name": "порядок списка не выбирает этаж", + "routes": [ + { "id": "r2", "source": "camera.robot", "map_id": "m2", "space": "floor2", "calibration": [2, 0, 0, 0, 2, 0] }, + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.robot": "m1" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ready", "route_id": "r1", "space": "floor1" } + }, + { + "name": "две камеры с неразличимым default одновременно активны", + "routes": [ + { "id": "r1", "source": "camera.floor1", "map_id": "default", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] }, + { "id": "r2", "source": "camera.floor2", "map_id": "default", "space": "floor2", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.floor1": "default", "camera.floor2": "default" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ambiguous", "route_ids": ["r1", "r2"] } + }, + { + "name": "две камеры, различимые map id — неоднозначности нет", + "routes": [ + { "id": "r1", "source": "camera.floor1", "map_id": "a", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] }, + { "id": "r2", "source": "camera.floor2", "map_id": "b", "space": "floor2", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.floor1": "x", "camera.floor2": "b" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ready", "route_id": "r2", "space": "floor2" } + }, + { + "name": "активная карта не размечена", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.robot": "m3" }, + "spaces": ["floor1"], + "expected": { "kind": "unmapped", "source": "camera.robot", "map_id": "m3" } + }, + { + "name": "маршрут без калибровки", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": null } + ], + "observed": { "camera.robot": "m1" }, + "spaces": ["floor1"], + "expected": { "kind": "needs_calibration", "route_id": "r1" } + }, + { + "name": "матрица из пяти чисел калибровкой не считается", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": [1, 0, 0, 0, 1] } + ], + "observed": { "camera.robot": "m1" }, + "spaces": ["floor1"], + "expected": { "kind": "needs_calibration", "route_id": "r1" } + }, + { + "name": "целевое пространство удалено", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "gone", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.robot": "m1" }, + "spaces": ["floor1"], + "expected": { "kind": "missing_space", "route_id": "r1" } + }, + { + "name": "нулевой map id значим и не путается с default", + "routes": [ + { "id": "r0", "source": "camera.robot", "map_id": "0", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] }, + { "id": "rd", "source": "camera.robot", "map_id": "default", "space": "floor2", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.robot": "0" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ready", "route_id": "r0", "space": "floor1" } + }, + { + "name": "пустая строка тоже идентификатор карты", + "routes": [ + { "id": "re", "source": "camera.robot", "map_id": "", "space": "floor2", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": { "camera.robot": "" }, + "spaces": ["floor1", "floor2"], + "expected": { "kind": "ready", "route_id": "re", "space": "floor2" } + }, + { + "name": "телеметрии нет ни у одного источника", + "routes": [ + { "id": "r1", "source": "camera.robot", "map_id": "m1", "space": "floor1", "calibration": [1, 0, 0, 0, 1, 0] } + ], + "observed": {}, + "spaces": ["floor1"], + "expected": { "kind": "none" } + }, + { + "name": "маршрутов нет вовсе, но карта наблюдается", + "routes": [], + "observed": { "camera.robot": "m9" }, + "spaces": ["floor1"], + "expected": { "kind": "unmapped", "source": "camera.robot", "map_id": "m9" } + } +] diff --git a/test/vacuum-routes.test.mjs b/test/vacuum-routes.test.mjs new file mode 100644 index 00000000..0f60bb34 --- /dev/null +++ b/test/vacuum-routes.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + validateMarkerRoutes, effectiveRoutes, legacyRouteId, resolveRoute, adoptLegacyRun, + normalizeRouteMatrix, isEntityIdLike, VAC_ROUTE_LIMIT, VAC_ROUTE_ERROR, +} from '../test-build/vacuum-routes.js'; + +const fixture = (name) => JSON.parse(readFileSync( + new URL(`./fixtures/vacuum-routes/${name}.json`, import.meta.url), 'utf8', +)); + +const IDENTITY = [1, 0, 0, 0, 1, 0]; +const route = (over = {}) => ({ + id: 'r1', source: 'camera.robot', map_id: 'm1', space: 'floor1', + calibration: IDENTITY, ...over, +}); +const spaces = (...ids) => new Set(ids); + +test('маршрут признаётся валидным только целиком', () => { + assert.deepEqual(validateMarkerRoutes('mk', [route()], spaces('floor1')), []); + assert.deepEqual(validateMarkerRoutes('mk', null, spaces('floor1')), []); + assert.deepEqual(validateMarkerRoutes('mk', undefined, spaces('floor1')), []); +}); + +test('пустой map id валиден, а нестрочный — нет', () => { + assert.deepEqual(validateMarkerRoutes('mk', [route({ map_id: '' })], spaces('floor1')), []); + const bad = validateMarkerRoutes('mk', [route({ map_id: 0 })], spaces('floor1')); + assert.deepEqual(bad.map((p) => p.reason), ['map_id']); + assert.equal(bad[0].code, VAC_ROUTE_ERROR); +}); + +test('идентичность (source, map_id) уникальна внутри маркера', () => { + const rows = [route(), route({ id: 'r2' })]; + assert.deepEqual( + validateMarkerRoutes('mk', rows, spaces('floor1')).map((p) => p.reason), + ['duplicate_identity'], + ); + const other = [route(), route({ id: 'r2', source: 'camera.second' })]; + assert.deepEqual(validateMarkerRoutes('mk', other, spaces('floor1')), []); +}); + +test('дубль id и ссылка на несуществующее пространство ловятся отдельно', () => { + const rows = [route(), route({ map_id: 'm2', space: 'gone' })]; + const reasons = validateMarkerRoutes('mk', rows, spaces('floor1')).map((p) => p.reason); + assert.deepEqual(reasons.sort(), ['duplicate_id', 'unknown_space']); + // spaceIds === null: превью импорта ещё не знает целевых пространств + assert.deepEqual( + validateMarkerRoutes('mk', [route({ space: 'gone' })], null), + [], + ); +}); + +test('источник обязан быть похож на entity id, матрица — из шести конечных чисел', () => { + assert.equal(isEntityIdLike('camera.robot'), true); + assert.equal(isEntityIdLike('camera'), false); + assert.equal(isEntityIdLike('Camera.Robot'), false); + assert.equal(normalizeRouteMatrix([1, 0, 0, 0, 1, 0]).length, 6); + assert.equal(normalizeRouteMatrix([1, 0, 0, 0, 1]), null); + assert.equal(normalizeRouteMatrix([1, 0, 0, 0, 1, NaN]), null); + assert.equal(normalizeRouteMatrix('нет'), null); + const reasons = validateMarkerRoutes('mk', [route({ source: 'camera', calibration: [1] })], spaces('floor1')) + .map((p) => p.reason).sort(); + assert.deepEqual(reasons, ['calibration', 'source']); +}); + +test('лимит маршрутов на маркер', () => { + const many = Array.from({ length: VAC_ROUTE_LIMIT + 1 }, (_, i) => route({ id: `r${i}`, map_id: `m${i}` })); + assert.ok(validateMarkerRoutes('mk', many, spaces('floor1')).some((p) => p.reason === 'limit')); + const exact = many.slice(0, VAC_ROUTE_LIMIT); + assert.deepEqual(validateMarkerRoutes('mk', exact, spaces('floor1')), []); +}); + +test('легаси-калибровки читаются как маршруты в пространство дока', () => { + const marker = { source: 'camera.robot', calibration: { m1: IDENTITY, m2: [2, 0, 0, 0, 2, 0], bad: [1, 2] } }; + const routes = effectiveRoutes('mk', marker, 'floor1'); + assert.deepEqual(routes.map((r) => r.map_id).sort(), ['m1', 'm2']); + assert.ok(routes.every((r) => r.space === 'floor1' && r.source === 'camera.robot')); + assert.equal(routes[0].id, legacyRouteId('mk', 'camera.robot', 'm1')); +}); + +test('явные маршруты вытесняют легаси-калибровку целиком', () => { + const marker = { + source: 'camera.robot', + calibration: { m1: IDENTITY }, + map_routes: [route({ id: 'r9', map_id: 'm9', space: 'floor2' })], + }; + const routes = effectiveRoutes('mk', marker, 'floor1'); + assert.deepEqual(routes.map((r) => r.id), ['r9']); + assert.equal(routes[0].space, 'floor2'); +}); + +test('без источника легаси-маршрутов не возникает, но discovery его подставляет', () => { + const marker = { calibration: { m1: IDENTITY } }; + assert.deepEqual(effectiveRoutes('mk', marker, 'floor1'), []); + const found = effectiveRoutes('mk', marker, 'floor1', 'camera.found'); + assert.deepEqual(found.map((r) => r.source), ['camera.found']); +}); + +test('общая фикстура: разрешение маршрута', () => { + for (const row of fixture('resolve')) { + const got = resolveRoute({ + routes: row.routes, observed: row.observed, spaceIds: spaces(...row.spaces), + }); + assert.equal(got.kind, row.expected.kind, row.name); + if (row.expected.route_id) assert.equal(got.route.id, row.expected.route_id, row.name); + if (row.expected.space) assert.equal(got.route.space, row.expected.space, row.name); + if (row.expected.route_ids) assert.deepEqual(got.routeIds, row.expected.route_ids, row.name); + if (row.expected.source) assert.equal(got.source, row.expected.source, row.name); + if (row.expected.map_id !== undefined) assert.equal(got.mapId, row.expected.map_id, row.name); + } +}); + +test('порядок списка маршрутов не влияет ни на один исход', () => { + for (const row of fixture('resolve')) { + const straight = resolveRoute({ routes: row.routes, observed: row.observed, spaceIds: spaces(...row.spaces) }); + const reversed = resolveRoute({ + routes: row.routes.slice().reverse(), observed: row.observed, spaceIds: spaces(...row.spaces), + }); + assert.deepEqual(reversed, straight, row.name); + } +}); + +test('общая фикстура: усыновление легаси-run', () => { + for (const row of fixture('legacy-run')) { + const got = adoptLegacyRun(row.run, row.routes, row.root_source); + assert.equal(got.kind, row.expected.kind, row.name); + if (row.expected.route_id) assert.equal(got.route.id, row.expected.route_id, row.name); + if (row.expected.route_ids) assert.deepEqual(got.routeIds, row.expected.route_ids, row.name); + } +}); + +test('run без map id усыновлению не подлежит', () => { + assert.equal(adoptLegacyRun(null, [route()], 'camera.robot').kind, 'orphan_run'); + assert.equal(adoptLegacyRun({}, [route()], 'camera.robot').kind, 'orphan_run'); + assert.equal(adoptLegacyRun({ map_id: 1 }, [route()], 'camera.robot').kind, 'orphan_run'); +}); diff --git a/tests_backend/test_vacuum_routes.py b/tests_backend/test_vacuum_routes.py new file mode 100644 index 00000000..77279b5d --- /dev/null +++ b/tests_backend/test_vacuum_routes.py @@ -0,0 +1,103 @@ +"""vacuum_routes.py — питоновское зеркало src/vacuum-routes.ts (#162). + +Модуль чистый и Home Assistant не тянет, поэтому импортируется напрямую: +sys.path/sys.modules не трогаются намеренно (класс #389). +""" +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(ROOT / "custom_components" / "houseplan")) +import vacuum_routes as vr # noqa: E402 + +FIXTURES = ROOT / "test" / "fixtures" / "vacuum-routes" +IDENTITY = [1, 0, 0, 0, 1, 0] + + +def _fixture(name): + return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + + +def _route(**over): + base = { + "id": "r1", "source": "camera.robot", "map_id": "m1", + "space": "floor1", "calibration": IDENTITY, + } + base.update(over) + return base + + +def test_matrix_normalisation_matches_frontend(): + assert vr.normalize_route_matrix(IDENTITY) == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0] + assert vr.normalize_route_matrix([1, 0, 0, 0, 1]) is None + assert vr.normalize_route_matrix([1, 0, 0, 0, 1, float("nan")]) is None + assert vr.normalize_route_matrix([1, 0, 0, 0, 1, True]) is None + assert vr.normalize_route_matrix("нет") is None + + +def test_validation_reasons(): + assert vr.validate_marker_routes("mk", [_route()], {"floor1"}) == [] + assert vr.validate_marker_routes("mk", None, {"floor1"}) == [] + assert [p["reason"] for p in vr.validate_marker_routes( + "mk", [_route(map_id="")], {"floor1"})] == [] + assert [p["reason"] for p in vr.validate_marker_routes( + "mk", [_route(map_id=0)], {"floor1"})] == ["map_id"] + assert [p["reason"] for p in vr.validate_marker_routes( + "mk", [_route(), _route(id="r2")], {"floor1"})] == ["duplicate_identity"] + assert sorted(p["reason"] for p in vr.validate_marker_routes( + "mk", [_route(), _route(map_id="m2", space="gone")], {"floor1"})) == [ + "duplicate_id", "unknown_space"] + # превью импорта ещё не знает целевых пространств + assert vr.validate_marker_routes("mk", [_route(space="gone")], None) == [] + many = [_route(id=f"r{i}", map_id=f"m{i}") for i in range(vr.VAC_ROUTE_LIMIT + 1)] + assert any(p["reason"] == "limit" for p in vr.validate_marker_routes("mk", many, {"floor1"})) + + +def test_legacy_calibration_reads_as_routes_into_dock_space(): + vacuum = {"source": "camera.robot", "calibration": {"m1": IDENTITY, "bad": [1, 2]}} + routes = vr.effective_routes("mk", vacuum, "floor1") + assert [r["map_id"] for r in routes] == ["m1"] + assert routes[0]["space"] == "floor1" + assert routes[0]["id"] == vr.legacy_route_id("mk", "camera.robot", "m1") + explicit = dict(vacuum, map_routes=[_route(id="r9", map_id="m9", space="floor2")]) + assert [r["id"] for r in vr.effective_routes("mk", explicit, "floor1")] == ["r9"] + + +def test_resolve_shared_fixture(): + for row in _fixture("resolve"): + got = vr.resolve_route(row["routes"], row["observed"], set(row["spaces"])) + assert got["kind"] == row["expected"]["kind"], row["name"] + if "route_id" in row["expected"]: + assert got["route"]["id"] == row["expected"]["route_id"], row["name"] + if "space" in row["expected"]: + assert got["route"]["space"] == row["expected"]["space"], row["name"] + if "route_ids" in row["expected"]: + assert got["routeIds"] == row["expected"]["route_ids"], row["name"] + if "source" in row["expected"]: + assert got["source"] == row["expected"]["source"], row["name"] + if "map_id" in row["expected"]: + assert got["mapId"] == row["expected"]["map_id"], row["name"] + + +def test_resolve_is_order_independent(): + for row in _fixture("resolve"): + straight = vr.resolve_route(row["routes"], row["observed"], set(row["spaces"])) + reversed_ = vr.resolve_route( + list(reversed(row["routes"])), row["observed"], set(row["spaces"])) + assert reversed_ == straight, row["name"] + + +def test_adopt_legacy_run_shared_fixture(): + for row in _fixture("legacy-run"): + got = vr.adopt_legacy_run(row["run"], row["routes"], row["root_source"]) + assert got["kind"] == row["expected"]["kind"], row["name"] + if "route_id" in row["expected"]: + assert got["route"]["id"] == row["expected"]["route_id"], row["name"] + if "route_ids" in row["expected"]: + assert got["routeIds"] == row["expected"]["route_ids"], row["name"] + + +def test_adopt_rejects_run_without_map_id(): + for run in (None, {}, {"map_id": 1}): + assert vr.adopt_legacy_run(run, [_route()], "camera.robot")["kind"] == "orphan_run" diff --git a/tsconfig.test.json b/tsconfig.test.json index 03124480..58a33faa 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -13,7 +13,7 @@ "src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts", "src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/device-area-relocation.ts", "src/ha-binding-status.ts", - "src/integration-provider.ts", "src/vacuum.ts", + "src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/sun.ts", "src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts", "src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",