mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
feat: pure map-to-space routing contracts for multi-floor vacuums
User-Visible: no Issue: #162
This commit is contained in:
+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');
|
||||
});
|
||||
Reference in New Issue
Block a user