mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
feat: pure map-to-space routing contracts for multi-floor vacuums
User-Visible: no Issue: #162
This commit is contained in:
@@ -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)}
|
||||
@@ -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 '
|
||||
|
||||
@@ -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<string, number[]> | 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<string> | 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<string>();
|
||||
const seenIdentity = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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<string, string> | Record<string, string>;
|
||||
spaceIds: Set<string> | 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() };
|
||||
}
|
||||
+67
@@ -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" }
|
||||
}
|
||||
]
|
||||
+113
@@ -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" }
|
||||
}
|
||||
]
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user