feat(map): open bounded telemetry card from entities

This commit is contained in:
Codex 2026-07-22 11:47:41 +03:00
parent b8fc4c5ba1
commit dd2febe7cf
8 changed files with 452 additions and 4 deletions

View File

@ -31,6 +31,7 @@ import {
findCameraSurveyPreset, findCameraSurveyPreset,
type CameraSurveySelection, type CameraSurveySelection,
} from "./mapCameraPresets.js"; } from "./mapCameraPresets.js";
import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@ -255,6 +256,13 @@ const defaultLayersWindowRect: WorkspaceWindowRect = {
height: 500, height: 500,
}; };
const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 52,
width: 390,
height: 560,
};
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) { function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state])); const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
return Object.fromEntries(bindings.map((binding, index) => { return Object.fromEntries(bindings.map((binding, index) => {
@ -307,6 +315,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { }>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const workspaceRef = useRef<HTMLDivElement>(null); const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>(); const [selectedId, setSelectedId] = useState<string>();
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect); const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
@ -381,6 +394,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
title: mapRuntimeDisplayLabel(fact, profile), title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType, kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus, status: presentationClass?.label ?? fact.presentationStatus,
bindingId: binding.bindingId,
dataProductId: binding.dataProductId,
fact,
}; };
}); });
}) })
@ -458,6 +474,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}] : []), }] : []),
], [spiralPresetId]); ], [spiralPresetId]);
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const selectedSubjectCard = useMemo(() => {
const entity = selectable.find((candidate) => candidate.id === selectedId);
return entity ? buildMapSubjectCardModel(entity.fact, {
title: entity.title,
bindingId: entity.bindingId,
dataProductId: entity.dataProductId,
}) : null;
}, [selectable, selectedId]);
const presentation = useMemo<MapPresentation>( const presentation = useMemo<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }), () => ({ ...mapSettings, cacheRefresh }),
[cacheRefresh, mapSettings], [cacheRefresh, mapSettings],
@ -670,12 +694,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}; };
const openSubjectWindow = (bindingId: string) => { const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({ updateSubjectState(bindingId, (state) => ({
...state, ...state,
window: { ...state.window, open: true, zIndex: nextZIndex }, window: { ...state.window, open: true, zIndex: nextZIndex },
})); }));
setLayersWindowActive(false); setLayersWindowActive(false);
setSubjectCardActive(false);
setActiveSubjectBindingId(bindingId); setActiveSubjectBindingId(bindingId);
}; };
@ -685,9 +710,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}; };
const activateLayersWindow = () => { const activateLayersWindow = () => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setLayersWindowZIndex(nextZIndex); setLayersWindowZIndex(nextZIndex);
setLayersWindowActive(true); setLayersWindowActive(true);
setSubjectCardActive(false);
setActiveSubjectBindingId(undefined); setActiveSubjectBindingId(undefined);
}; };
@ -718,7 +744,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const handleSelect = useCallback((entityId: string) => { const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return; if (!selectable.some((entity) => entity.id === entityId)) return;
setSelectedId(entityId); setSelectedId(entityId);
}, [selectable]); setSubjectCardOpen(true);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [layersWindowZIndex, selectable, subjectStates]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => { const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health; gatewayHealthRef.current = health;
@ -1312,6 +1343,65 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
); );
})} })}
{subjectCardOpen && selectedSubjectCard ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={subjectCardRect}
onRectChange={setSubjectCardRect}
maximized={subjectCardMaximized}
onMaximizedChange={setSubjectCardMaximized}
onActivate={() => {
const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setSubjectCardZIndex(nextZIndex);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
}}
onClose={() => {
setSubjectCardOpen(false);
setSubjectCardActive(false);
}}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={subjectCardActive}
zIndex={subjectCardZIndex}
minWidth={320}
minHeight={320}
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
aria-label={`Телеметрия: ${selectedSubjectCard.title}`}
>
<div className="catalog-map-subject-card">
{selectedSubjectCard.sections.map((section) => (
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${section.id}`}>
<h3 id={`subject-card-${section.id}`}>{section.label}</h3>
<dl>
{section.rows.map((row) => (
<div key={row.key} className="catalog-map-subject-card__row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</section>
))}
<section className="catalog-map-subject-card__section" aria-labelledby="subject-card-readings">
<h3 id="subject-card-readings">Датчики</h3>
{selectedSubjectCard.readings.length ? (
<div className="catalog-map-subject-card__readings">
{selectedSubjectCard.readings.map((reading) => (
<div className="catalog-map-subject-card__reading" key={reading.id}>
<span>{reading.label}</span>
<strong>{reading.value}</strong>
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
</div>
))}
</div>
) : <small>Gelios не передал разрешённых значений датчиков для этого объекта.</small>}
</section>
</div>
</WorkspaceWindow>
) : null}
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null} {assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button> <button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>

View File

@ -0,0 +1,18 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapSubjectCardRow = { key: string; label: string; value: string };
export type MapSubjectCardSection = { id: string; label: string; rows: MapSubjectCardRow[] };
export type MapTelemetryReading = { id: string; label: string; value: string; observedAt?: string };
export type MapSubjectCardModel = {
title: string;
sourceId: string;
sections: MapSubjectCardSection[];
readings: MapTelemetryReading[];
};
export function buildMapSubjectCardModel(
fact: MapRuntimeFact,
context?: { title?: string; bindingId?: string; dataProductId?: string },
): MapSubjectCardModel | null;
export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[];

View File

@ -0,0 +1,125 @@
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
const FIELD_PRESENTATION = Object.freeze({
signal_state: { section: "state", label: "Связь" },
movement_state: { section: "state", label: "Движение" },
speed_kph: { section: "position", label: "Скорость", unit: "км/ч" },
course_degrees: { section: "position", label: "Курс", unit: "°" },
elevation_meters: { section: "position", label: "Высота", unit: "м" },
satellite_count: { section: "quality", label: "Спутники" },
hdop: { section: "quality", label: "HDOP" },
horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" },
mileage_km: { section: "operation", label: "Пробег", unit: "км" },
engine_hours: { section: "operation", label: "Моточасы", unit: "ч" },
object_kind: { section: "identity", label: "Класс объекта" },
position_source: { section: "identity", label: "Источник позиции" },
});
const SECTION_ORDER = Object.freeze([
["state", "Состояние"],
["position", "Позиция"],
["quality", "Качество GPS"],
["operation", "Эксплуатация"],
["identity", "Идентификация"],
["other", "Прочие данные"],
]);
export function buildMapSubjectCardModel(fact, context = {}) {
if (!fact || typeof fact !== "object") return null;
const attributes = isPlainObject(fact.attributes) ? fact.attributes : {};
const rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }]));
const append = (section, key, label, value, unit) => {
if (value === undefined || SECRET_LIKE_KEY.test(key)) return;
rows.get(section)?.rows.push({ key, label, value: formatValue(key, value, unit) });
};
append("identity", "source_id", "ID", fact.sourceId);
append("identity", "semantic_type", "Семантический тип", fact.semanticType);
append("identity", "data_product_id", "Продукт данных", context.dataProductId);
append("identity", "binding_id", "Binding", context.bindingId);
append("state", "presentation_status", "Статус отображения", fact.presentationStatus);
if (fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) {
append("position", "latitude", "Широта", fact.geometry.coordinates[1]);
append("position", "longitude", "Долгота", fact.geometry.coordinates[0]);
}
append("state", "observed_at", "Данные объекта", fact.observedAt);
append("state", "received_at", "Получено NODE.DC", fact.receivedAt);
for (const [key, value] of Object.entries(attributes)) {
if (key === "display_name" || key === "sensor_readings" || SECRET_LIKE_KEY.test(key)) continue;
const presentation = FIELD_PRESENTATION[key];
append(presentation?.section ?? "other", key, presentation?.label ?? humanizeKey(key), value, presentation?.unit);
}
const readings = normalizeTelemetryReadings(attributes.sensor_readings);
return {
title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект",
sourceId: stringValue(fact.sourceId),
sections: [...rows.values()].filter((section) => section.rows.length),
readings,
};
}
export function normalizeTelemetryReadings(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value.slice(0, 128).flatMap((candidate) => {
if (!isPlainObject(candidate)) return [];
const id = stringValue(candidate.id);
const label = stringValue(candidate.label);
if (!/^[a-z][a-z0-9._:-]{1,127}$/.test(id) || !label || seen.has(id) || SECRET_LIKE_KEY.test(id)) return [];
if (!isScalar(candidate.value)) return [];
seen.add(id);
const unit = stringValue(candidate.unit).slice(0, 32);
const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined;
return [{ id, label: label.slice(0, 160), value: formatScalar(candidate.value, unit), observedAt }];
});
}
function formatValue(key, value, unit) {
if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value);
if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", ");
if (isPlainObject(value)) return JSON.stringify(value);
return formatScalar(value, unit);
}
function formatScalar(value, unit = "") {
if (typeof value === "boolean") return value ? "Да" : "Нет";
if (typeof value === "number") {
const number = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 3 }).format(value);
return unit ? `${number} ${unit}` : number;
}
const text = stringValue(value);
return unit && text ? `${text} ${unit}` : text || "—";
}
function formatTimestamp(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
}
function humanizeKey(value) {
const text = String(value).replace(/[._-]+/g, " ").trim();
return text ? text[0].toUpperCase() + text.slice(1) : String(value);
}
function isScalar(value) {
return typeof value === "string"
|| typeof value === "boolean"
|| (typeof value === "number" && Number.isFinite(value));
}
function stringValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function isIsoTimestamp(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@ -808,6 +808,95 @@ textarea {
padding: 0.42rem 0.65rem 0.68rem; padding: 0.42rem 0.65rem 0.68rem;
} }
.catalog-map-fixture__subject-card {
--nodedc-radius-modal: 1.45rem;
}
.catalog-map-fixture__subject-card .nodedc-workspace-window__body {
overflow: auto;
padding: 0.35rem 0.65rem 0.75rem;
}
.catalog-map-subject-card {
display: grid;
gap: 0.65rem;
padding-bottom: 0.2rem;
}
.catalog-map-subject-card__section {
display: grid;
gap: 0.36rem;
border-radius: var(--nodedc-radius-control);
background: rgba(255, 255, 255, 0.46);
padding: 0.72rem 0.78rem;
}
.catalog-map-subject-card__section h3,
.catalog-map-subject-card__section dl,
.catalog-map-subject-card__section dt,
.catalog-map-subject-card__section dd {
margin: 0;
}
.catalog-map-subject-card__section h3 {
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-xs);
font-weight: 820;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.catalog-map-subject-card__section dl,
.catalog-map-subject-card__readings {
display: grid;
gap: 0.14rem;
}
.catalog-map-subject-card__row,
.catalog-map-subject-card__reading {
display: grid;
min-width: 0;
grid-template-columns: minmax(7.5rem, 0.85fr) minmax(0, 1.15fr);
align-items: baseline;
gap: 0.6rem;
border-top: 1px solid rgba(13, 14, 17, 0.08);
padding: 0.34rem 0;
}
.catalog-map-subject-card__row:first-child,
.catalog-map-subject-card__reading:first-child {
border-top: 0;
}
.catalog-map-subject-card__row dt,
.catalog-map-subject-card__reading span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.catalog-map-subject-card__row dd,
.catalog-map-subject-card__reading strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-sm);
font-weight: 760;
line-height: 1.35;
}
.catalog-map-subject-card__reading time {
grid-column: 2;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.catalog-map-subject-card__section > small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.catalog-map-fixture__target-filters, .catalog-map-fixture__target-filters,
.catalog-map-fixture__target-filters section { .catalog-map-fixture__target-filters section {
display: grid; display: grid;

View File

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-subject-card",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
@ -24,6 +24,7 @@
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", "test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs" "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
}, },

View File

@ -48,6 +48,26 @@
}, },
"removeMode": "canonical-tombstone-or-snapshot-rebase" "removeMode": "canonical-tombstone-or-snapshot-rebase"
}, },
{
"id": "map-moving-object-current-v5",
"version": "5.0.0",
"dataProductId": "fleet.positions.current.v5",
"productVersion": "5.0.0",
"staleAfterMs": null,
"terminalStatuses": [
"inactive"
],
"statusContract": {
"attribute": "signal_state",
"allowedValues": [
"active",
"inactive"
],
"missing": "reject",
"freshness": "none"
},
"removeMode": "canonical-tombstone-or-snapshot-rebase"
},
{ {
"id": "map-zone-current-v1", "id": "map-zone-current-v1",
"version": "1.0.0", "version": "1.0.0",

View File

@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { buildMapSubjectCardModel, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
const fact = {
sourceId: "867236078012345",
semanticType: "map.moving_object",
presentationStatus: "current",
observedAt: "2026-07-22T08:42:03.000Z",
receivedAt: "2026-07-22T08:42:05.000Z",
geometry: { type: "Point", coordinates: [37.6176, 55.7558] },
attributes: {
display_name: "555590 B2 867236078012345",
signal_state: "active",
movement_state: "moving",
speed_kph: 17.4,
course_degrees: 211,
elevation_meters: 142,
satellite_count: 12,
hdop: 0.7,
horizontal_accuracy_meters: 3.5,
mileage_km: 4832.25,
engine_hours: 912.5,
object_kind: "trike",
position_source: "gelios",
sensor_readings: [
{ id: "sensor.temperature", label: "Температура", value: 21.5, unit: "°C", observedAt: "2026-07-22T08:42:02.000Z" },
],
},
};
test("subject card exposes the full provider-neutral telemetry projection", () => {
const card = buildMapSubjectCardModel(fact, {
bindingId: "trike-current-positions",
dataProductId: "fleet.positions.current.v5",
});
assert.ok(card);
assert.equal(card.title, "555590 B2 867236078012345");
assert.equal(card.sourceId, fact.sourceId);
const values = new Map(card.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value])));
assert.equal(values.get("source_id"), fact.sourceId);
assert.equal(values.get("data_product_id"), "fleet.positions.current.v5");
assert.equal(values.get("binding_id"), "trike-current-positions");
assert.equal(values.get("latitude"), "55,756");
assert.equal(values.get("longitude"), "37,618");
assert.equal(values.get("signal_state"), "На связи");
assert.equal(values.get("movement_state"), "В движении");
assert.equal(values.get("speed_kph"), "17,4 км/ч");
assert.equal(values.get("mileage_km"), "4 832,25 км");
assert.equal(values.get("engine_hours"), "912,5 ч");
assert.equal(card.readings[0]?.value, "21,5 °C");
});
test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => {
const readings = normalizeTelemetryReadings([
{ id: "sensor.voltage", label: "Напряжение", value: 48.2, unit: "V" },
{ id: "sensor.voltage", label: "Повтор", value: 49 },
{ id: "sensor.imei", label: "IMEI", value: "forbidden" },
{ id: "sensor.payload", label: "Payload", value: { nested: true } },
{ id: "Sensor Bad", label: "Bad id", value: 1 },
]);
assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]);
});
test("card model never renders provider secrets or direct identifiers from attributes", () => {
const card = buildMapSubjectCardModel({
...fact,
attributes: {
...fact.attributes,
api_key: "forbidden",
imei: "forbidden",
phone: "+70000000000",
raw_params: "forbidden",
},
});
const keys = card.sections.flatMap((section) => section.rows.map((row) => row.key));
assert.ok(!keys.includes("api_key"));
assert.ok(!keys.includes("imei"));
assert.ok(!keys.includes("phone"));
assert.ok(!keys.includes("raw_params"));
});
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
const [renderer, preview] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
]);
assert.match(renderer, /viewer\?\.scene\.pick/);
assert.match(renderer, /pickedId instanceof Entity/);
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
assert.match(preview, /const handleSelect = useCallback/);
assert.match(preview, /setSubjectCardOpen\(true\)/);
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
assert.match(preview, /Телеметрия:/);
});

View File

@ -110,6 +110,12 @@ requireValue(movingObjectV4Policy?.statusContract?.attribute === "signal_state",
requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive"); requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive");
requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states"); requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states");
const movingObjectV5Policy = consumerPolicies.find((policy) => policy.dataProductId === "fleet.positions.current.v5" && policy.productVersion === "5.0.0");
requireValue(movingObjectV5Policy?.id === "map-moving-object-current-v5", "fleet.positions.current.v5 consumer policy is missing");
requireValue(movingObjectV5Policy?.statusContract?.attribute === "signal_state", "fleet.positions.current.v5 status source must be signal_state");
requireValue(JSON.stringify(movingObjectV5Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v5 signal states must remain closed active/inactive");
requireValue(movingObjectV5Policy?.statusContract?.freshness === "none" && movingObjectV5Policy?.staleAfterMs === null, "fleet.positions.current.v5 must not invent freshness states");
const zoneV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v1" && policy.productVersion === "1.0.0"); const zoneV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v1" && policy.productVersion === "1.0.0");
requireValue(JSON.stringify(zoneV1Policy) === JSON.stringify({ requireValue(JSON.stringify(zoneV1Policy) === JSON.stringify({
id: "map-zone-current-v1", id: "map-zone-current-v1",