feat(foundry): add composable subject detail profiles

This commit is contained in:
Codex 2026-07-22 19:06:33 +03:00
parent dd2febe7cf
commit dc82ae4c07
21 changed files with 1007 additions and 140 deletions

View File

@ -1,5 +1,5 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react"; import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react"; import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type { import type {
CameraSpiralState, CameraSpiralState,
@ -31,7 +31,7 @@ import {
findCameraSurveyPreset, findCameraSurveyPreset,
type CameraSurveySelection, type CameraSurveySelection,
} from "./mapCameraPresets.js"; } from "./mapCameraPresets.js";
import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs"; import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } 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 };
@ -112,6 +112,36 @@ export type MapDataProductBinding = {
semanticTypes: string[]; semanticTypes: string[];
fieldProjection: string[]; fieldProjection: string[];
presentationProfileId?: string; presentationProfileId?: string;
subjectDetailProfileId?: string;
aspectId?: string;
joinToBindingId?: string;
};
export type MapSubjectDetailProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
defaultTabId: string;
tabs: Array<{
id: string;
label: string;
emptyMessage: string;
sections: Array<{
id: string;
label: string;
fields: Array<{
id: string;
aspectId?: string;
source: "fact" | "attribute" | "geometry" | "context";
field: string;
label: string;
format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings";
unit?: string;
allowedReadingIds?: string[];
}>;
}>;
}>;
}; };
export type MapSubjectWindowState = { export type MapSubjectWindowState = {
@ -137,6 +167,7 @@ export type MapPageLayout = {
camera: MapCameraView; camera: MapCameraView;
pinBindings: MapPinBinding[]; pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[]; presentationProfiles: MapPresentationProfile[];
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[]; dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[]; subjectStates: MapSubjectState[];
savedAt?: string; savedAt?: string;
@ -216,6 +247,7 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
camera: { ...fallbackMapCamera }, camera: { ...fallbackMapCamera },
pinBindings: [], pinBindings: [],
presentationProfiles: [], presentationProfiles: [],
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [], dataProductBindings: [],
subjectStates: [], subjectStates: [],
}; };
@ -320,6 +352,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false); const [subjectCardActive, setSubjectCardActive] = useState(false);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
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);
@ -356,6 +389,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => ( const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []) normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
)); ));
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
initialLayout?.subjectDetailProfiles?.length
? initialLayout.subjectDetailProfiles
: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile]
));
// Data-product bindings are provisioned by Foundry MCP / Platform and do // Data-product bindings are provisioned by Foundry MCP / Platform and do
// not belong to the visual inspector. Preserve them verbatim when a human // not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout. // edits camera or presentation settings and saves the page layout.
@ -379,6 +417,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const selectable = useMemo(() => ( const selectable = useMemo(() => (
runtimeBindings.flatMap((binding) => { runtimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
if (bindingConfig?.joinToBindingId) return [];
const facts = [...binding.facts]; const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact( const primaryProfile = mapPresentationProfileForFact(
presentationProfiles, presentationProfiles,
@ -476,12 +515,33 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const selectedSubjectCard = useMemo(() => { const selectedSubjectCard = useMemo(() => {
const entity = selectable.find((candidate) => candidate.id === selectedId); const entity = selectable.find((candidate) => candidate.id === selectedId);
return entity ? buildMapSubjectCardModel(entity.fact, { if (!entity) return null;
const primaryBinding = dataProductBindings.find((binding) => binding.id === entity.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === primaryBinding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity.fact.semanticType))
?? DEFAULT_MAP_SUBJECT_DETAIL_PROFILE;
const aspects = Object.fromEntries(dataProductBindings
.filter((binding) => binding.id === entity.bindingId || binding.joinToBindingId === entity.bindingId)
.flatMap((binding) => {
const runtime = runtimeBindings.find((candidate) => candidate.bindingId === binding.id);
const fact = binding.id === entity.bindingId
? entity.fact
: runtime?.facts.find((candidate) => candidate.sourceId === entity.fact.sourceId);
if (!fact) return [];
return [[binding.id === entity.bindingId ? "primary" : (binding.aspectId ?? binding.id), {
fact,
bindingId: binding.id,
dataProductId: binding.dataProductId,
}]];
}));
return buildMapSubjectCardModel(entity.fact, {
title: entity.title, title: entity.title,
bindingId: entity.bindingId, bindingId: entity.bindingId,
dataProductId: entity.dataProductId, dataProductId: entity.dataProductId,
}) : null; profile,
}, [selectable, selectedId]); aspects,
});
}, [dataProductBindings, runtimeBindings, selectable, selectedId, subjectDetailProfiles]);
const presentation = useMemo<MapPresentation>( const presentation = useMemo<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }), () => ({ ...mapSettings, cacheRefresh }),
[cacheRefresh, mapSettings], [cacheRefresh, mapSettings],
@ -650,6 +710,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings, pinBindings,
presentationProfiles, presentationProfiles,
subjectDetailProfiles,
dataProductBindings, dataProductBindings,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? { subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id, bindingId: binding.id,
@ -663,7 +724,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
: state; : state;
}), }),
}), }),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectStates]); }), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectDetailProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => { const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => { setSubjectStates((current) => {
@ -744,12 +805,17 @@ 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);
const entity = selectable.find((candidate) => candidate.id === entityId);
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? ""));
setSubjectCardTabId(profile?.defaultTabId ?? "overview");
setSubjectCardOpen(true); setSubjectCardOpen(true);
setSubjectCardActive(true); setSubjectCardActive(true);
setLayersWindowActive(false); setLayersWindowActive(false);
setActiveSubjectBindingId(undefined); setActiveSubjectBindingId(undefined);
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1); setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [layersWindowZIndex, selectable, subjectStates]); }, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => { const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health; gatewayHealthRef.current = health;
@ -1368,12 +1434,28 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
minWidth={320} minWidth={320}
minHeight={320} minHeight={320}
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window" className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
aria-label={`Телеметрия: ${selectedSubjectCard.title}`} aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
> >
<div className="catalog-map-subject-card"> <div className="catalog-map-subject-card">
{selectedSubjectCard.sections.map((section) => ( <div className="catalog-map-subject-card__tabs">
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${section.id}`}> <SegmentedControl
<h3 id={`subject-card-${section.id}`}>{section.label}</h3> value={selectedSubjectCard.tabs.some((tab) => tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId}
items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
label="Разделы карточки объекта"
onChange={setSubjectCardTabId}
/>
</div>
{selectedSubjectCard.tabs.filter((tab) => tab.id === (
selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId)
? subjectCardTabId
: selectedSubjectCard.defaultTabId
)).map((tab) => (
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
{tab.sections.map((section) => (
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
{section.rows.length ? (
<dl> <dl>
{section.rows.map((row) => ( {section.rows.map((row) => (
<div key={row.key} className="catalog-map-subject-card__row"> <div key={row.key} className="catalog-map-subject-card__row">
@ -1382,13 +1464,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
</div> </div>
))} ))}
</dl> </dl>
</section> ) : null}
))} {section.readings.length ? (
<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"> <div className="catalog-map-subject-card__readings">
{selectedSubjectCard.readings.map((reading) => ( {section.readings.map((reading) => (
<div className="catalog-map-subject-card__reading" key={reading.id}> <div className="catalog-map-subject-card__reading" key={reading.id}>
<span>{reading.label}</span> <span>{reading.label}</span>
<strong>{reading.value}</strong> <strong>{reading.value}</strong>
@ -1396,8 +1475,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
</div> </div>
))} ))}
</div> </div>
) : <small>Gelios не передал разрешённых значений датчиков для этого объекта.</small>} ) : null}
</section> </section>
))}
</div>
))}
</div> </div>
</WorkspaceWindow> </WorkspaceWindow>
) : null} ) : null}

View File

@ -1,18 +1,64 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapSubjectDetailField = {
id: string;
aspectId?: string;
source: "fact" | "attribute" | "geometry" | "context";
field: string;
label: string;
format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings";
unit?: string;
allowedReadingIds?: string[];
};
export type MapSubjectDetailProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
defaultTabId: string;
tabs: Array<{
id: string;
label: string;
emptyMessage: string;
sections: Array<{ id: string; label: string; fields: MapSubjectDetailField[] }>;
}>;
};
export type MapSubjectCardRow = { key: string; label: string; value: string }; 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 MapTelemetryReading = { id: string; label: string; value: string; observedAt?: string };
export type MapSubjectCardSection = {
id: string;
label: string;
rows: MapSubjectCardRow[];
readings: MapTelemetryReading[];
};
export type MapSubjectCardTab = {
id: string;
label: string;
emptyMessage: string;
sections: MapSubjectCardSection[];
empty: boolean;
};
export type MapSubjectCardModel = { export type MapSubjectCardModel = {
title: string; title: string;
sourceId: string; sourceId: string;
sections: MapSubjectCardSection[]; profileId: string;
readings: MapTelemetryReading[]; defaultTabId: string;
tabs: MapSubjectCardTab[];
}; };
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE: Readonly<MapSubjectDetailProfile>;
export function buildMapSubjectCardModel( export function buildMapSubjectCardModel(
fact: MapRuntimeFact, fact: MapRuntimeFact,
context?: { title?: string; bindingId?: string; dataProductId?: string }, context?: {
title?: string;
bindingId?: string;
dataProductId?: string;
profile?: MapSubjectDetailProfile;
aspects?: Record<string, { fact: MapRuntimeFact; bindingId?: string; dataProductId?: string }>;
},
): MapSubjectCardModel | null; ): MapSubjectCardModel | null;
export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[]; export function normalizeTelemetryReadings(value: unknown, allowedReadingIds?: string[]): MapTelemetryReading[];

View File

@ -1,75 +1,184 @@
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i; const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
const DIRECT_IDENTIFIER_VALUE = /^(?:\+?\d[\d ()-]{8,18}|\d{14,16})$/;
const FIELD_PRESENTATION = Object.freeze({ export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({
signal_state: { section: "state", label: "Связь" }, id: "map.subject-detail.operational.default",
movement_state: { section: "state", label: "Движение" }, version: "1.0.0",
speed_kph: { section: "position", label: "Скорость", unit: "км/ч" }, title: "Техническая карточка объекта",
course_degrees: { section: "position", label: "Курс", unit: "°" }, semanticTypes: ["map.moving_object"],
elevation_meters: { section: "position", label: "Высота", unit: "м" }, defaultTabId: "overview",
satellite_count: { section: "quality", label: "Спутники" }, tabs: [
hdop: { section: "quality", label: "HDOP" }, {
horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" }, id: "overview",
mileage_km: { section: "operation", label: "Пробег", unit: "км" }, label: "Обзор",
engine_hours: { section: "operation", label: "Моточасы", unit: "ч" }, emptyMessage: "Оперативные данные объекта пока не получены.",
object_kind: { section: "identity", label: "Класс объекта" }, sections: [
position_source: { section: "identity", label: "Источник позиции" }, {
id: "state",
label: "Состояние",
fields: [
{ id: "overview.signal", aspectId: "primary", source: "attribute", field: "signal_state", label: "Связь", format: "signal_state" },
{ id: "overview.movement", aspectId: "primary", source: "attribute", field: "movement_state", label: "Движение", format: "movement_state" },
{ id: "overview.speed", aspectId: "primary", source: "attribute", field: "speed_kph", label: "Скорость", format: "number", unit: "км/ч" },
{ id: "overview.observed", aspectId: "primary", source: "fact", field: "observedAt", label: "Данные объекта", format: "timestamp" },
],
},
],
},
{
id: "position",
label: "Позиция",
emptyMessage: "Для объекта нет разрешённой позиции.",
sections: [
{
id: "coordinates",
label: "Координаты и движение",
fields: [
{ id: "position.latitude", aspectId: "primary", source: "geometry", field: "latitude", label: "Широта", format: "coordinate" },
{ id: "position.longitude", aspectId: "primary", source: "geometry", field: "longitude", label: "Долгота", format: "coordinate" },
{ id: "position.course", aspectId: "primary", source: "attribute", field: "course_degrees", label: "Курс", format: "number", unit: "°" },
{ id: "position.elevation", aspectId: "primary", source: "attribute", field: "elevation_meters", label: "Высота", format: "number", unit: "м" },
],
},
{
id: "quality",
label: "Качество GPS",
fields: [
{ id: "position.satellites", aspectId: "primary", source: "attribute", field: "satellite_count", label: "Спутники", format: "number" },
{ id: "position.hdop", aspectId: "primary", source: "attribute", field: "hdop", label: "HDOP", format: "number" },
{ id: "position.accuracy", aspectId: "primary", source: "attribute", field: "horizontal_accuracy_meters", label: "Точность", format: "number", unit: "м" },
],
},
],
},
{
id: "telemetry",
label: "Телеметрия",
emptyMessage: "Разрешённые сенсоры появятся после профилирования подключения.",
sections: [{
id: "readings",
label: "Датчики",
fields: [{
id: "telemetry.readings",
aspectId: "primary",
source: "attribute",
field: "sensor_readings",
label: "Датчики",
format: "telemetry_readings",
allowedReadingIds: [],
}],
}],
},
{
id: "counters",
label: "Счётчики",
emptyMessage: "Счётчики для объекта не получены.",
sections: [{
id: "operation",
label: "Эксплуатация",
fields: [
{ id: "counters.mileage", aspectId: "primary", source: "attribute", field: "mileage_km", label: "Пробег", format: "number", unit: "км" },
{ id: "counters.engine-hours", aspectId: "primary", source: "attribute", field: "engine_hours", label: "Моточасы", format: "number", unit: "ч" },
],
}],
},
{ id: "equipment", label: "Оборудование", emptyMessage: "Технический профиль оборудования ещё не подключён.", sections: [] },
{ id: "settings", label: "Настройки", emptyMessage: "Trip/fuel settings ещё не подключены.", sections: [] },
{ id: "maintenance", label: "ТО", emptyMessage: "Планы технического обслуживания ещё не подключены.", sections: [] },
{
id: "diagnostics",
label: "Данные",
emptyMessage: "Диагностические метаданные не получены.",
sections: [{
id: "provenance",
label: "Происхождение",
fields: [
{ id: "diagnostics.source-id", aspectId: "primary", source: "fact", field: "sourceId", label: "ID объекта", format: "text" },
{ id: "diagnostics.semantic-type", aspectId: "primary", source: "fact", field: "semanticType", label: "Семантический тип", format: "text" },
{ id: "diagnostics.product", aspectId: "primary", source: "context", field: "dataProductId", label: "Продукт данных", format: "text" },
{ id: "diagnostics.received", aspectId: "primary", source: "fact", field: "receivedAt", label: "Получено NODE.DC", format: "timestamp" },
],
}],
},
],
}); });
const SECTION_ORDER = Object.freeze([
["state", "Состояние"],
["position", "Позиция"],
["quality", "Качество GPS"],
["operation", "Эксплуатация"],
["identity", "Идентификация"],
["other", "Прочие данные"],
]);
export function buildMapSubjectCardModel(fact, context = {}) { export function buildMapSubjectCardModel(fact, context = {}) {
if (!fact || typeof fact !== "object") return null; if (!isPlainObject(fact)) return null;
const profile = isPlainObject(context.profile) ? context.profile : DEFAULT_MAP_SUBJECT_DETAIL_PROFILE;
const primary = { fact, dataProductId: context.dataProductId, bindingId: context.bindingId };
const aspects = { primary, ...(isPlainObject(context.aspects) ? context.aspects : {}) };
const tabs = Array.isArray(profile.tabs) ? profile.tabs.flatMap((tab) => buildTab(tab, aspects)) : [];
if (!tabs.length) return null;
const attributes = isPlainObject(fact.attributes) ? fact.attributes : {}; const attributes = isPlainObject(fact.attributes) ? fact.attributes : {};
const rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }])); const defaultTabId = tabs.some((tab) => tab.id === profile.defaultTabId) ? profile.defaultTabId : tabs[0].id;
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 { return {
title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект", title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект",
sourceId: stringValue(fact.sourceId), sourceId: stringValue(fact.sourceId),
sections: [...rows.values()].filter((section) => section.rows.length), profileId: stringValue(profile.id),
readings, defaultTabId,
tabs,
}; };
} }
export function normalizeTelemetryReadings(value) { function buildTab(tab, aspects) {
if (!Array.isArray(value)) return []; if (!isPlainObject(tab) || !safeIdentifier(tab.id) || !stringValue(tab.label)) return [];
const sections = Array.isArray(tab.sections) ? tab.sections.flatMap((section) => buildSection(section, aspects)) : [];
return [{
id: tab.id,
label: tab.label,
emptyMessage: stringValue(tab.emptyMessage) || "Данные для этого раздела пока не получены.",
sections,
empty: sections.length === 0,
}];
}
function buildSection(section, aspects) {
if (!isPlainObject(section) || !safeIdentifier(section.id) || !stringValue(section.label)) return [];
const rows = [];
const readings = [];
for (const field of Array.isArray(section.fields) ? section.fields : []) {
if (!isPlainObject(field) || SECRET_LIKE.test(String(field.field || "")) || SECRET_LIKE.test(String(field.label || ""))) continue;
const aspect = aspects[stringValue(field.aspectId) || "primary"];
if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue;
const value = resolveFieldValue(aspect, field);
if (field.format === "telemetry_readings") {
readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds));
continue;
}
if (value === undefined || value === null || value === "") continue;
rows.push({
key: String(field.id || `${field.source}.${field.field}`),
label: stringValue(field.label),
value: formatValue(field.format, value, stringValue(field.unit)),
});
}
if (!rows.length && !readings.length) return [];
return [{ id: section.id, label: section.label, rows, readings }];
}
function resolveFieldValue(aspect, field) {
const fact = aspect.fact;
if (field.source === "attribute") return isPlainObject(fact.attributes) ? fact.attributes[field.field] : undefined;
if (field.source === "fact") return fact[field.field];
if (field.source === "context") return aspect[field.field];
if (field.source === "geometry" && fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) {
if (field.field === "latitude") return fact.geometry.coordinates[1];
if (field.field === "longitude") return fact.geometry.coordinates[0];
}
return undefined;
}
export function normalizeTelemetryReadings(value, allowedReadingIds = []) {
if (!Array.isArray(value) || !Array.isArray(allowedReadingIds) || allowedReadingIds.length === 0) return [];
const allowed = new Set(allowedReadingIds.filter((item) => safeIdentifier(item) && !SECRET_LIKE.test(item)));
const seen = new Set(); const seen = new Set();
return value.slice(0, 128).flatMap((candidate) => { return value.slice(0, 128).flatMap((candidate) => {
if (!isPlainObject(candidate)) return []; if (!isPlainObject(candidate)) return [];
const id = stringValue(candidate.id); const id = stringValue(candidate.id);
const label = stringValue(candidate.label); 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 (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return [];
if (!isScalar(candidate.value)) return []; if (!isSafeScalar(candidate.value)) return [];
seen.add(id); seen.add(id);
const unit = stringValue(candidate.unit).slice(0, 32); const unit = stringValue(candidate.unit).slice(0, 32);
const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined; const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined;
@ -77,12 +186,14 @@ export function normalizeTelemetryReadings(value) {
}); });
} }
function formatValue(key, value, unit) { function formatValue(format, value, unit) {
if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value); if (format === "timestamp") return isIsoTimestamp(value) ? formatTimestamp(value) : "—";
if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit); if (format === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit); if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", "); if (format === "boolean") return Boolean(value) ? "Да" : "Нет";
if (isPlainObject(value)) return JSON.stringify(value); if (format === "coordinate" && typeof value === "number" && Number.isFinite(value)) {
return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value);
}
return formatScalar(value, unit); return formatScalar(value, unit);
} }
@ -101,15 +212,16 @@ function formatTimestamp(value) {
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
} }
function humanizeKey(value) { function isSafeScalar(value) {
const text = String(value).replace(/[._-]+/g, " ").trim(); if (typeof value === "boolean") return true;
return text ? text[0].toUpperCase() + text.slice(1) : String(value); if (typeof value === "number") return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) < 1_000_000_000_000);
if (typeof value !== "string") return false;
const normalized = value.trim();
return normalized.length <= 512 && !SECRET_LIKE.test(normalized) && !DIRECT_IDENTIFIER_VALUE.test(normalized);
} }
function isScalar(value) { function safeIdentifier(value) {
return typeof value === "string" return typeof value === "string" && /^[a-z][a-z0-9._:-]{1,159}$/.test(value);
|| typeof value === "boolean"
|| (typeof value === "number" && Number.isFinite(value));
} }
function stringValue(value) { function stringValue(value) {

View File

@ -823,6 +823,35 @@ textarea {
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
} }
.catalog-map-subject-card__tabs {
overflow-x: auto;
padding: 0.08rem 0 0.12rem;
scrollbar-width: thin;
}
.catalog-map-subject-card__tabs .nodedc-segmented {
width: max-content;
min-width: 100%;
}
.catalog-map-subject-card__tabs .nodedc-segmented__item {
white-space: nowrap;
}
.catalog-map-subject-card__tab-panel {
display: grid;
gap: 0.65rem;
}
.catalog-map-subject-card__empty {
border-radius: var(--nodedc-radius-control);
background: rgba(255, 255, 255, 0.34);
padding: 0.9rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.catalog-map-subject-card__section { .catalog-map-subject-card__section {
display: grid; display: grid;
gap: 0.36rem; gap: 0.36rem;

View File

@ -43,6 +43,22 @@ profile; renderer не содержит provider-specific условий.
Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout. Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout.
Клик по pin или его label открывает второе canonical `WorkspaceWindow` — карточку
выбранного объекта. Её вкладки и поля задаёт versioned provider-neutral
`subjectDetailProfile`, а не renderer и не provider payload. Базовый профиль
находится в `registry/map-subject-detail-profiles.json`. Он разделяет обзор,
позицию, телеметрию, счётчики, оборудование, настройки, ТО и происхождение
данных. Неизвестные поля не получают автоматическую вкладку `Other`: UI
показывает только явно классифицированную проекцию. Динамические показания
сенсоров требуют отдельного `allowedReadingIds`; пустой список означает явный
запрет вывода.
Несколько Data Product могут собираться в одну карточку без смешивания
контрактов. Primary binding владеет `subjectDetailProfileId`, а
`subject-aspect-stream` bindings указывают `joinToBindingId` и уникальный
`aspectId`. Соединение выполняется по стабильному `sourceId`; provider endpoint,
credentials и raw payload в Application Manifest не попадают.
Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием. Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
## Acceptance fixtures ## Acceptance fixtures
@ -98,8 +114,10 @@ Runtime tile cache не является исходным кодом и не х
## Live data-product runtime ## Live data-product runtime
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved `Map Page` хранит только provider-neutral binding: `dataProductId`, approved
semantic types, field projection, `presentationProfileId` и `slotId`: `points` semantic types, field projection, `presentationProfileId`, optional
для live point entities или `zones` для polygon/multipolygon `map.zone`. `subjectDetailProfileId`, `aspectId`/`joinToBindingId` и `slotId`: `points`
для live point entities, `zones` для polygon/multipolygon `map.zone` или
`subject-details` для non-spatial current aspects выбранного объекта.
При открытии Application page browser делает same-origin запрос к Foundry: При открытии Application page browser делает same-origin запрос к Foundry:
```text ```text

View File

@ -97,7 +97,7 @@ Foundry предоставляет базовый MCP-контур для уже
- `Page Library` и канонические шаблоны доступны только на чтение; - `Page Library` и канонические шаблоны доступны только на чтение;
- изменяются только экземпляры в `Applications`; - изменяются только экземпляры в `Applications`;
- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles и data-product bindings; - доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles, versioned subject-detail profiles и data-product bindings;
- удаление модуля через MCP намеренно отсутствует; - удаление модуля через MCP намеренно отсутствует;
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store. - каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.

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 && npm run test:map-subject-card", "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 && npm run test:map-subject-detail-profile",
"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",
@ -25,6 +25,7 @@
"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-subject-card": "node --test scripts/map-subject-card.test.mjs",
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.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

@ -0,0 +1,106 @@
{
"schemaVersion": "nodedc.map-subject-detail-profiles/v1",
"profiles": [
{
"id": "map.subject-detail.operational.default",
"version": "1.0.0",
"title": "Техническая карточка объекта",
"semanticTypes": ["map.moving_object"],
"defaultTabId": "overview",
"tabs": [
{
"id": "overview",
"label": "Обзор",
"emptyMessage": "Оперативные данные объекта пока не получены.",
"sections": [
{
"id": "state",
"label": "Состояние",
"fields": [
{ "id": "overview.signal", "source": "attribute", "field": "signal_state", "label": "Связь", "format": "signal_state" },
{ "id": "overview.movement", "source": "attribute", "field": "movement_state", "label": "Движение", "format": "movement_state" },
{ "id": "overview.speed", "source": "attribute", "field": "speed_kph", "label": "Скорость", "format": "number", "unit": "км/ч" },
{ "id": "overview.observed", "source": "fact", "field": "observedAt", "label": "Данные объекта", "format": "timestamp" }
]
}
]
},
{
"id": "position",
"label": "Позиция",
"emptyMessage": "Для объекта нет разрешённой позиции.",
"sections": [
{
"id": "coordinates",
"label": "Координаты и движение",
"fields": [
{ "id": "position.latitude", "source": "geometry", "field": "latitude", "label": "Широта", "format": "coordinate" },
{ "id": "position.longitude", "source": "geometry", "field": "longitude", "label": "Долгота", "format": "coordinate" },
{ "id": "position.course", "source": "attribute", "field": "course_degrees", "label": "Курс", "format": "number", "unit": "°" },
{ "id": "position.elevation", "source": "attribute", "field": "elevation_meters", "label": "Высота", "format": "number", "unit": "м" }
]
},
{
"id": "quality",
"label": "Качество GPS",
"fields": [
{ "id": "position.satellites", "source": "attribute", "field": "satellite_count", "label": "Спутники", "format": "number" },
{ "id": "position.hdop", "source": "attribute", "field": "hdop", "label": "HDOP", "format": "number" },
{ "id": "position.accuracy", "source": "attribute", "field": "horizontal_accuracy_meters", "label": "Точность", "format": "number", "unit": "м" }
]
}
]
},
{
"id": "telemetry",
"label": "Телеметрия",
"emptyMessage": "Разрешённые сенсоры появятся после профилирования подключения.",
"sections": [
{
"id": "readings",
"label": "Датчики",
"fields": [
{ "id": "telemetry.readings", "source": "attribute", "field": "sensor_readings", "label": "Датчики", "format": "telemetry_readings", "allowedReadingIds": [] }
]
}
]
},
{
"id": "counters",
"label": "Счётчики",
"emptyMessage": "Счётчики для объекта не получены.",
"sections": [
{
"id": "operation",
"label": "Эксплуатация",
"fields": [
{ "id": "counters.mileage", "source": "attribute", "field": "mileage_km", "label": "Пробег", "format": "number", "unit": "км" },
{ "id": "counters.engine-hours", "source": "attribute", "field": "engine_hours", "label": "Моточасы", "format": "number", "unit": "ч" }
]
}
]
},
{ "id": "equipment", "label": "Оборудование", "emptyMessage": "Технический профиль оборудования ещё не подключён.", "sections": [] },
{ "id": "settings", "label": "Настройки", "emptyMessage": "Trip/fuel settings ещё не подключены.", "sections": [] },
{ "id": "maintenance", "label": "ТО", "emptyMessage": "Планы технического обслуживания ещё не подключены.", "sections": [] },
{
"id": "diagnostics",
"label": "Данные",
"emptyMessage": "Диагностические метаданные не получены.",
"sections": [
{
"id": "provenance",
"label": "Происхождение",
"fields": [
{ "id": "diagnostics.source-id", "source": "fact", "field": "sourceId", "label": "ID объекта", "format": "text" },
{ "id": "diagnostics.semantic-type", "source": "fact", "field": "semanticType", "label": "Семантический тип", "format": "text" },
{ "id": "diagnostics.product", "source": "context", "field": "dataProductId", "label": "Продукт данных", "format": "text" },
{ "id": "diagnostics.received", "source": "fact", "field": "receivedAt", "label": "Получено NODE.DC", "format": "timestamp" }
]
}
]
}
]
}
]
}

View File

@ -24,6 +24,7 @@
{ "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true }, { "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true },
{ "id": "routes", "kind": "route-stream", "label": "Routes", "description": "Planned or computed routes.", "required": false, "multiple": true }, { "id": "routes", "kind": "route-stream", "label": "Routes", "description": "Planned or computed routes.", "required": false, "multiple": true },
{ "id": "zones", "kind": "zone-stream", "label": "Zones", "description": "Polygons, geofences and operational areas.", "required": false, "multiple": true }, { "id": "zones", "kind": "zone-stream", "label": "Zones", "description": "Polygons, geofences and operational areas.", "required": false, "multiple": true },
{ "id": "subject-details", "kind": "subject-aspect-stream", "label": "Subject details", "description": "Provider-neutral current aspects joined to a stable selected subject.", "required": false, "multiple": true },
{ "id": "selection", "kind": "selection", "label": "Selection", "description": "Selected map entity and contextual state.", "required": false, "multiple": false }, { "id": "selection", "kind": "selection", "label": "Selection", "description": "Selected map entity and contextual state.", "required": false, "multiple": false },
{ "id": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true }, { "id": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true },
{ "id": "assistant", "kind": "assistant", "label": "Assistant context", "description": "Context exposed to the assistant overlay.", "required": false, "multiple": false } { "id": "assistant", "kind": "assistant", "label": "Assistant context", "description": "Context exposed to the assistant overlay.", "required": false, "multiple": false }

View File

@ -55,5 +55,6 @@
"mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json", "mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json",
"mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json", "mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json",
"mapPresentationProfileRegistry": "map-presentation-profiles.json", "mapPresentationProfileRegistry": "map-presentation-profiles.json",
"mapSubjectDetailProfileRegistry": "map-subject-detail-profiles.json",
"operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy." "operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
} }

View File

@ -80,7 +80,27 @@
"properties": { "properties": {
"id": { "type": "string", "minLength": 1 }, "id": { "type": "string", "minLength": 1 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 120 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 120 },
"order": { "type": "integer", "minimum": 0, "maximum": 10000 } "order": { "type": "integer", "minimum": 0, "maximum": 10000 },
"aspectId": { "type": "string", "minLength": 1, "maxLength": 160 },
"joinToBindingId": { "type": "string", "minLength": 1, "maxLength": 128 },
"subjectDetailProfileId": { "type": "string", "minLength": 1, "maxLength": 160 }
}
}
},
"subjectDetailProfiles": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
"title": { "type": "string", "minLength": 1, "maxLength": 120 },
"semanticTypes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "string" } },
"defaultTabId": { "type": "string", "minLength": 1 },
"tabs": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "type": "object" } }
} }
} }
}, },

View File

@ -1,10 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { buildMapSubjectCardModel, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs"; import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
const fact = { const fact = {
sourceId: "867236078012345", sourceId: "gelios-unit-555590",
semanticType: "map.moving_object", semanticType: "map.moving_object",
presentationStatus: "current", presentationStatus: "current",
observedAt: "2026-07-22T08:42:03.000Z", observedAt: "2026-07-22T08:42:03.000Z",
@ -31,26 +31,32 @@ const fact = {
}; };
test("subject card exposes the full provider-neutral telemetry projection", () => { test("subject card exposes the full provider-neutral telemetry projection", () => {
const profile = structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE);
profile.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.temperature"];
const card = buildMapSubjectCardModel(fact, { const card = buildMapSubjectCardModel(fact, {
bindingId: "trike-current-positions", bindingId: "trike-current-positions",
dataProductId: "fleet.positions.current.v5", dataProductId: "fleet.positions.current.v5",
profile,
}); });
assert.ok(card); assert.ok(card);
assert.equal(card.title, "555590 B2 867236078012345"); assert.equal(card.title, "555590 B2 867236078012345");
assert.equal(card.sourceId, fact.sourceId); assert.equal(card.sourceId, fact.sourceId);
const values = new Map(card.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))); assert.equal(card.defaultTabId, "overview");
assert.equal(values.get("source_id"), fact.sourceId); assert.deepEqual(card.tabs.map((tab) => tab.id), ["overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics"]);
assert.equal(values.get("data_product_id"), "fleet.positions.current.v5"); const values = new Map(card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))));
assert.equal(values.get("binding_id"), "trike-current-positions"); assert.equal(values.get("diagnostics.source-id"), fact.sourceId);
assert.equal(values.get("latitude"), "55,756"); assert.equal(values.get("diagnostics.product"), "fleet.positions.current.v5");
assert.equal(values.get("longitude"), "37,618"); assert.equal(values.get("position.latitude"), "55,75580");
assert.equal(values.get("signal_state"), "На связи"); assert.equal(values.get("position.longitude"), "37,61760");
assert.equal(values.get("movement_state"), "В движении"); assert.equal(values.get("overview.signal"), "На связи");
assert.equal(values.get("speed_kph"), "17,4 км/ч"); assert.equal(values.get("overview.movement"), "В движении");
assert.equal(values.get("mileage_km"), "4 832,25 км"); assert.equal(values.get("overview.speed"), "17,4 км/ч");
assert.equal(values.get("engine_hours"), "912,5 ч"); assert.equal(values.get("counters.mileage"), "4 832,25 км");
assert.equal(card.readings[0]?.value, "21,5 °C"); assert.equal(values.get("counters.engine-hours"), "912,5 ч");
const readings = card.tabs.find((tab) => tab.id === "telemetry").sections[0].readings;
assert.equal(readings[0]?.value, "21,5 °C");
assert.equal(card.tabs.find((tab) => tab.id === "equipment").empty, true);
}); });
test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => { test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => {
@ -60,7 +66,8 @@ test("sensor readings remain bounded and reject duplicate, complex and restricte
{ id: "sensor.imei", label: "IMEI", value: "forbidden" }, { id: "sensor.imei", label: "IMEI", value: "forbidden" },
{ id: "sensor.payload", label: "Payload", value: { nested: true } }, { id: "sensor.payload", label: "Payload", value: { nested: true } },
{ id: "Sensor Bad", label: "Bad id", value: 1 }, { id: "Sensor Bad", label: "Bad id", value: 1 },
]); { id: "sensor.account", label: "Аккаунт", value: "+79991234567" },
], ["sensor.voltage", "sensor.imei", "sensor.payload", "sensor.account"]);
assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]); assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]);
}); });
@ -76,13 +83,55 @@ test("card model never renders provider secrets or direct identifiers from attri
raw_params: "forbidden", raw_params: "forbidden",
}, },
}); });
const keys = card.sections.flatMap((section) => section.rows.map((row) => row.key)); const keys = card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => row.key)));
assert.ok(!keys.includes("api_key")); assert.ok(!keys.includes("api_key"));
assert.ok(!keys.includes("imei")); assert.ok(!keys.includes("imei"));
assert.ok(!keys.includes("phone")); assert.ok(!keys.includes("phone"));
assert.ok(!keys.includes("raw_params")); assert.ok(!keys.includes("raw_params"));
}); });
test("unknown fields are fail-closed instead of becoming an automatic Other section", () => {
const card = buildMapSubjectCardModel({
...fact,
attributes: { ...fact.attributes, unclassified_payload: { arbitrary: true }, surprise_value: "must not render" },
});
const keys = card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => row.key)));
assert.ok(!keys.includes("unclassified_payload"));
assert.ok(!keys.includes("surprise_value"));
assert.equal(card.tabs.some((tab) => tab.id === "other"), false);
});
test("subject details compose declared aspect bindings by stable source id", () => {
const profile = {
id: "map.subject-detail.composed",
version: "1.0.0",
title: "Composed",
semanticTypes: ["map.moving_object"],
defaultTabId: "equipment",
tabs: [{
id: "equipment",
label: "Оборудование",
emptyMessage: "Нет данных",
sections: [{
id: "identity",
label: "Профиль",
fields: [{ id: "equipment.kind", aspectId: "technical", source: "attribute", field: "equipment_kind", label: "Тип", format: "text" }],
}],
}],
};
const card = buildMapSubjectCardModel(fact, {
profile,
aspects: {
technical: {
bindingId: "trike-technical-profile",
dataProductId: "fleet.technical-profile.current.v1",
fact: { sourceId: fact.sourceId, semanticType: "asset.technical_profile", attributes: { equipment_kind: "cargo_trike" } },
},
},
});
assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike");
});
test("Cesium pin and label share one entity selection that opens the subject card", async () => { test("Cesium pin and label share one entity selection that opens the subject card", async () => {
const [renderer, preview] = await Promise.all([ const [renderer, preview] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
@ -95,5 +144,6 @@ test("Cesium pin and label share one entity selection that opens the subject car
assert.match(preview, /const handleSelect = useCallback/); assert.match(preview, /const handleSelect = useCallback/);
assert.match(preview, /setSubjectCardOpen\(true\)/); assert.match(preview, /setSubjectCardOpen\(true\)/);
assert.match(preview, /title=\{selectedSubjectCard\.title\}/); assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
assert.match(preview, /Телеметрия:/); assert.match(preview, /Карточка объекта:/);
assert.match(preview, /SegmentedControl/);
}); });

View File

@ -323,12 +323,13 @@ try {
return payload.result; return payload.result;
}; };
const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } }); const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } });
assert.equal(initialized.serverInfo.version, "0.5.0"); assert.equal(initialized.serverInfo.version, "0.6.0");
const listedTools = await mcpRequest("tools/list"); const listedTools = await mcpRequest("tools/list");
assert.equal(listedTools.tools.length, 17); assert.equal(listedTools.tools.length, 18);
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer"));
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_save_map_page_view_state")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_save_map_page_view_state"));
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding"));
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_upsert_map_subject_detail_profile"));
const workloadHeaders = { const workloadHeaders = {
authorization: `Bearer ${bindingGrantToken}`, authorization: `Bearer ${bindingGrantToken}`,

View File

@ -1,6 +1,7 @@
import { access, readFile } from "node:fs/promises"; import { access, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { normalizeMapSubjectDetailProfiles } from "../server/map-subject-detail-profile.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const parse = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8")); const parse = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8"));
@ -16,6 +17,7 @@ const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.sc
const mapSceneFixtureSchema = await parse("registry/schemas/map-scene-fixture-v0.1.schema.json"); const mapSceneFixtureSchema = await parse("registry/schemas/map-scene-fixture-v0.1.schema.json");
const mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json"); const mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json");
const mapPresentationProfiles = await parse("registry/map-presentation-profiles.json"); const mapPresentationProfiles = await parse("registry/map-presentation-profiles.json");
const mapSubjectDetailProfiles = await parse("registry/map-subject-detail-profiles.json");
const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json"); const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json");
const failures = []; const failures = [];
@ -43,12 +45,20 @@ requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schem
requireValue(registry.mapSceneFixtureSchema === "schemas/map-scene-fixture-v0.1.schema.json", "map scene fixture schema must be registered"); requireValue(registry.mapSceneFixtureSchema === "schemas/map-scene-fixture-v0.1.schema.json", "map scene fixture schema must be registered");
requireValue(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered"); requireValue(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered");
requireValue(registry.mapPresentationProfileRegistry === "map-presentation-profiles.json", "map presentation profile registry must be registered"); requireValue(registry.mapPresentationProfileRegistry === "map-presentation-profiles.json", "map presentation profile registry must be registered");
requireValue(registry.mapSubjectDetailProfileRegistry === "map-subject-detail-profiles.json", "map subject detail profile registry must be registered");
requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0"); requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0");
requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0"); requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0");
requireValue(mapSceneFixtureSchema.properties?.schemaVersion?.const === "0.1.0", "map scene fixture schema version must be 0.1.0"); requireValue(mapSceneFixtureSchema.properties?.schemaVersion?.const === "0.1.0", "map scene fixture schema version must be 0.1.0");
requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0"); requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0");
requireValue(mapPresentationProfiles.schemaVersion === "nodedc.map-presentation-profiles/v1", "map presentation profile registry schema is unsupported"); requireValue(mapPresentationProfiles.schemaVersion === "nodedc.map-presentation-profiles/v1", "map presentation profile registry schema is unsupported");
requireValue(Array.isArray(mapPresentationProfiles.profiles) && mapPresentationProfiles.profiles.length >= 1, "map presentation profiles are missing"); requireValue(Array.isArray(mapPresentationProfiles.profiles) && mapPresentationProfiles.profiles.length >= 1, "map presentation profiles are missing");
requireValue(mapSubjectDetailProfiles.schemaVersion === "nodedc.map-subject-detail-profiles/v1", "map subject detail profile registry schema is unsupported");
try {
const normalizedSubjectDetailProfiles = normalizeMapSubjectDetailProfiles(mapSubjectDetailProfiles.profiles);
requireValue(normalizedSubjectDetailProfiles.length >= 1, "map subject detail profiles are missing");
} catch (error) {
requireValue(false, `invalid map subject detail profile registry: ${error instanceof Error ? error.message : String(error)}`);
}
requireValue(dataProductConsumerPolicies.schemaVersion === "nodedc.foundry.data-product-consumer-policies/v1", "data product consumer policy registry schema is unsupported"); requireValue(dataProductConsumerPolicies.schemaVersion === "nodedc.foundry.data-product-consumer-policies/v1", "data product consumer policy registry schema is unsupported");
requireValue(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing"); requireValue(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing");

View File

@ -19,6 +19,7 @@ import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-prov
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs"; import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
import { isMapLiveDataSlot } from "./map-live-data-slot.mjs"; import { isMapLiveDataSlot } from "./map-live-data-slot.mjs";
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs"; import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
import { normalizeMapSubjectDetailProfile, normalizeMapSubjectDetailProfiles } from "./map-subject-detail-profile.mjs";
import { createFoundryAuth } from "./nodedc-auth.mjs"; import { createFoundryAuth } from "./nodedc-auth.mjs";
const root = fileURLToPath(new URL("..", import.meta.url)); const root = fileURLToPath(new URL("..", import.meta.url));
@ -54,6 +55,11 @@ if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-p
throw new Error("map_presentation_profile_registry_invalid"); throw new Error("map_presentation_profile_registry_invalid");
} }
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles); const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
const mapSubjectDetailProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-subject-detail-profiles.json"), "utf8"));
if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") {
throw new Error("map_subject_detail_profile_registry_invalid");
}
const canonicalMapSubjectDetailProfiles = normalizeMapSubjectDetailProfiles(mapSubjectDetailProfileRegistry.profiles);
const foundryAuth = createFoundryAuth(); const foundryAuth = createFoundryAuth();
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout( const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS, process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
@ -333,6 +339,15 @@ function validateMapDataProductBinding(value) {
const presentationProfileId = value.presentationProfileId === undefined const presentationProfileId = value.presentationProfileId === undefined
? null ? null
: requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160); : requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160);
const subjectDetailProfileId = value.subjectDetailProfileId === undefined
? null
: requireNonEmptyString(value.subjectDetailProfileId, "invalid_map_data_product_subject_detail_profile_id", 160);
const aspectId = value.aspectId === undefined
? "primary"
: requireNonEmptyString(value.aspectId, "invalid_map_data_product_aspect_id", 160);
const joinToBindingId = value.joinToBindingId === undefined
? null
: requireNonEmptyString(value.joinToBindingId, "invalid_map_data_product_join_binding_id", 128);
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id"); if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id");
if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id"); if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id");
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot"); if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot");
@ -345,6 +360,13 @@ function validateMapDataProductBinding(value) {
if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) { if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) {
throw applicationError("invalid_map_data_product_presentation_profile_id"); throw applicationError("invalid_map_data_product_presentation_profile_id");
} }
if (subjectDetailProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(subjectDetailProfileId)) {
throw applicationError("invalid_map_data_product_subject_detail_profile_id");
}
if (!/^[a-z][a-z0-9._:-]{1,159}$/.test(aspectId)) throw applicationError("invalid_map_data_product_aspect_id");
if (joinToBindingId && !/^[A-Za-z0-9._:-]+$/.test(joinToBindingId)) {
throw applicationError("invalid_map_data_product_join_binding_id");
}
if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) { if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) {
throw applicationError("map_data_product_binding_contains_transport"); throw applicationError("map_data_product_binding_contains_transport");
} }
@ -358,6 +380,9 @@ function validateMapDataProductBinding(value) {
...(displayName ? { displayName } : {}), ...(displayName ? { displayName } : {}),
...(order !== null ? { order } : {}), ...(order !== null ? { order } : {}),
...(presentationProfileId ? { presentationProfileId } : {}), ...(presentationProfileId ? { presentationProfileId } : {}),
...(subjectDetailProfileId ? { subjectDetailProfileId } : {}),
aspectId,
...(joinToBindingId ? { joinToBindingId } : {}),
}; };
} }
@ -470,12 +495,39 @@ function validateMapPageLayout(value) {
requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`); requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
} }
const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles); const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
const subjectDetailProfiles = normalizeMapSubjectDetailProfiles(
value.subjectDetailProfiles === undefined
? structuredClone(canonicalMapSubjectDetailProfiles)
: value.subjectDetailProfiles,
);
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings); const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings); const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id)); const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) { if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
throw applicationError("map_data_product_presentation_profile_not_found"); throw applicationError("map_data_product_presentation_profile_not_found");
} }
const subjectDetailProfilesById = new Map(subjectDetailProfiles.map((profile) => [profile.id, profile]));
const bindingsById = new Map(dataProductBindings.map((binding) => [binding.id, binding]));
for (const binding of dataProductBindings) {
const detailProfile = binding.subjectDetailProfileId
? subjectDetailProfilesById.get(binding.subjectDetailProfileId)
: null;
if (binding.subjectDetailProfileId && !detailProfile) throw applicationError("map_data_product_subject_detail_profile_not_found");
if (detailProfile && !binding.semanticTypes.some((semanticType) => detailProfile.semanticTypes.includes(semanticType))) {
throw applicationError("map_data_product_subject_detail_profile_semantic_type_mismatch");
}
if (binding.joinToBindingId) {
if (binding.joinToBindingId === binding.id || !bindingsById.has(binding.joinToBindingId)) {
throw applicationError("map_data_product_join_binding_not_found");
}
if (binding.subjectDetailProfileId) throw applicationError("map_data_product_joined_binding_owns_detail_profile");
}
}
for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) {
const aspectIds = [primary, ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id)]
.map((binding) => binding.aspectId);
if (new Set(aspectIds).size !== aspectIds.length) throw applicationError("duplicate_map_data_product_aspect_id");
}
return { return {
schemaVersion: 1, schemaVersion: 1,
pageId: "map", pageId: "map",
@ -491,6 +543,7 @@ function validateMapPageLayout(value) {
}, },
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings), pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
presentationProfiles, presentationProfiles,
subjectDetailProfiles,
dataProductBindings, dataProductBindings,
subjectStates, subjectStates,
}; };
@ -582,6 +635,7 @@ function defaultMapPageLayout() {
}, },
pinBindings: [], pinBindings: [],
presentationProfiles: structuredClone(canonicalMapPresentationProfiles), presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles),
dataProductBindings: [], dataProductBindings: [],
subjectStates: [], subjectStates: [],
}; };
@ -1806,7 +1860,7 @@ const foundryMcpOperations = {
const config = foundryMcpConfig(); const config = foundryMcpConfig();
return { return {
module: "NDC Module Foundry", module: "NDC Module Foundry",
schemaVersion: "nodedc.module-foundry.mcp.v0.5", schemaVersion: "nodedc.module-foundry.mcp.v0.6",
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required", status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
canonicalPageLibrary: "read-only", canonicalPageLibrary: "read-only",
applicationInstances: "read-write", applicationInstances: "read-write",
@ -1819,6 +1873,7 @@ const foundryMcpOperations = {
"map-pin.upsert", "map-pin.upsert",
"map-pin.remove", "map-pin.remove",
"map-presentation-profile.upsert", "map-presentation-profile.upsert",
"map-subject-detail-profile.upsert",
"map-page-settings.update", "map-page-settings.update",
"map-page-view-state.save", "map-page-view-state.save",
"map-data-product.upsert", "map-data-product.upsert",
@ -1993,6 +2048,38 @@ const foundryMcpOperations = {
}, },
}); });
}, },
async upsertMapSubjectDetailProfile(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_upsert_map_subject_detail_profile",
actor,
input,
action: async () => {
const profile = normalizeMapSubjectDetailProfile(input.profile);
let updatedPage = null;
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
...current,
pages: current.pages.map((page) => {
if (page.id !== input.pageId) return page;
if (page.template?.id !== "map") throw applicationError("map_page_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
const subjectDetailProfiles = layout.subjectDetailProfiles.filter((item) => item.id !== profile.id);
subjectDetailProfiles.push(profile);
updatedPage = {
...page,
layout: { ...page.layout, map: validateMapPageLayout({
...layout,
subjectDetailProfiles,
savedAt: new Date().toISOString(),
}) },
};
return updatedPage;
}),
}));
if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, profile };
},
});
},
async updateMapPageSettings(input, actor) { async updateMapPageSettings(input, actor) {
return executeFoundryMcpWrite({ return executeFoundryMcpWrite({
tool: "foundry_update_map_page_settings", tool: "foundry_update_map_page_settings",

View File

@ -126,6 +126,14 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
assert.equal(presentationTool.inputSchema.properties.profile.properties.label.properties.sizePx.minimum, 8); assert.equal(presentationTool.inputSchema.properties.profile.properties.label.properties.sizePx.minimum, 8);
assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties.label.properties, "fontSizePx"), false); assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties.label.properties, "fontSizePx"), false);
assert.deepEqual(presentationTool.inputSchema.properties.profile.properties.label.properties.mode.enum, ["subject_id", "attributes", "none"]); assert.deepEqual(presentationTool.inputSchema.properties.profile.properties.label.properties.mode.enum, ["subject_id", "attributes", "none"]);
const subjectDetailTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_subject_detail_profile");
assert.ok(subjectDetailTool);
assert.equal(subjectDetailTool.inputSchema.properties.profile.additionalProperties, false);
assert.deepEqual(subjectDetailTool.inputSchema.properties.profile.required, ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
assert.deepEqual(
subjectDetailTool.inputSchema.properties.profile.properties.tabs.items.properties.sections.items.properties.fields.items.properties.format.enum,
["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"],
);
const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings"); const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
assert.ok(mapSettingsTool); assert.ok(mapSettingsTool);
assert.equal(mapSettingsTool.inputSchema.properties.settings.additionalProperties, false); assert.equal(mapSettingsTool.inputSchema.properties.settings.additionalProperties, false);
@ -139,6 +147,9 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
const bindingTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_data_product_binding"); const bindingTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_data_product_binding");
assert.equal(bindingTool.inputSchema.properties.binding.properties.displayName.maxLength, 120); assert.equal(bindingTool.inputSchema.properties.binding.properties.displayName.maxLength, 120);
assert.equal(bindingTool.inputSchema.properties.binding.properties.order.maximum, 10000); assert.equal(bindingTool.inputSchema.properties.binding.properties.order.maximum, 10000);
assert.ok(bindingTool.inputSchema.properties.binding.properties.subjectDetailProfileId);
assert.ok(bindingTool.inputSchema.properties.binding.properties.aspectId);
assert.ok(bindingTool.inputSchema.properties.binding.properties.joinToBindingId);
const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, { const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST", method: "POST",

View File

@ -303,6 +303,73 @@ const mapPresentationProfileInputSchema = {
}, },
}; };
const mapSubjectDetailProfileFieldInputSchema = {
type: "object",
additionalProperties: false,
required: ["id", "source", "field", "label", "format"],
properties: {
id: { type: "string", description: "Stable field presentation id." },
aspectId: { type: "string", description: "Provider-neutral subject aspect id; defaults to primary." },
source: { type: "string", enum: ["fact", "attribute", "geometry", "context"] },
field: { type: "string", description: "Exact registered fact, context, geometry or provider-neutral attribute field." },
label: { type: "string", minLength: 1, maxLength: 100 },
format: {
type: "string",
enum: ["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"],
},
unit: { type: "string", minLength: 1, maxLength: 24 },
allowedReadingIds: {
type: "array",
maxItems: 256,
uniqueItems: true,
description: "Explicit classified sensor ids. Empty means no dynamic readings are rendered.",
items: { type: "string" },
},
},
};
const mapSubjectDetailProfileInputSchema = {
type: "object",
additionalProperties: false,
required: ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"],
properties: {
id: { type: "string", description: "Stable provider-neutral subject.detail_profile id." },
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
title: { type: "string", minLength: 1, maxLength: 120 },
semanticTypes: { type: "array", minItems: 1, maxItems: 8, uniqueItems: true, items: { type: "string" } },
defaultTabId: { type: "string" },
tabs: {
type: "array",
minItems: 1,
maxItems: 12,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "emptyMessage", "sections"],
properties: {
id: { type: "string" },
label: { type: "string", minLength: 1, maxLength: 80 },
emptyMessage: { type: "string", minLength: 1, maxLength: 240 },
sections: {
type: "array",
maxItems: 12,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "fields"],
properties: {
id: { type: "string" },
label: { type: "string", minLength: 1, maxLength: 80 },
fields: { type: "array", maxItems: 32, items: mapSubjectDetailProfileFieldInputSchema },
},
},
},
},
},
},
},
};
const mapPageSettingsPatchInputSchema = { const mapPageSettingsPatchInputSchema = {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
@ -551,6 +618,22 @@ const tools = [
}, },
}, },
}, },
{
name: "foundry_upsert_map_subject_detail_profile",
title: "Upsert Map subject detail profile",
description: "Create or update a versioned provider-neutral subject detail profile. Tabs and fields are exact, fail-closed presentation declarations; provider transport and unrestricted payload rendering are forbidden.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "profile"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
profile: mapSubjectDetailProfileInputSchema,
},
},
},
{ {
name: "foundry_update_map_page_settings", name: "foundry_update_map_page_settings",
title: "Update Application Map settings", title: "Update Application Map settings",
@ -629,6 +712,9 @@ const tools = [
semanticTypes: { type: "array", items: { type: "string" } }, semanticTypes: { type: "array", items: { type: "string" } },
fieldProjection: { type: "array", items: { type: "string" } }, fieldProjection: { type: "array", items: { type: "string" } },
presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." }, presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." },
subjectDetailProfileId: { type: "string", description: "Existing page-owned provider-neutral subject detail profile id." },
aspectId: { type: "string", description: "Stable provider-neutral aspect id inside the selected subject composition." },
joinToBindingId: { type: "string", description: "Primary Map binding whose stable sourceId is used to join this subject-details aspect." },
}, },
}, },
}, },
@ -728,6 +814,7 @@ function toolMap(operations) {
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor), foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
foundry_remove_map_pin_binding: (input, actor) => operations.removeMapPinBinding(input, actor), foundry_remove_map_pin_binding: (input, actor) => operations.removeMapPinBinding(input, actor),
foundry_upsert_map_presentation_profile: (input, actor) => operations.upsertMapPresentationProfile(input, actor), foundry_upsert_map_presentation_profile: (input, actor) => operations.upsertMapPresentationProfile(input, actor),
foundry_upsert_map_subject_detail_profile: (input, actor) => operations.upsertMapSubjectDetailProfile(input, actor),
foundry_update_map_page_settings: (input, actor) => operations.updateMapPageSettings(input, actor), foundry_update_map_page_settings: (input, actor) => operations.updateMapPageSettings(input, actor),
foundry_save_map_page_view_state: (input, actor) => operations.saveMapPageViewState(input, actor), foundry_save_map_page_view_state: (input, actor) => operations.saveMapPageViewState(input, actor),
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor), foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
@ -772,7 +859,7 @@ export async function handleFoundryMcpRequest(request, response, options) {
return sendJson(response, 200, mcpResult(id, { return sendJson(response, 200, mcpResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION, protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } }, capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc_module_foundry", version: "0.5.0" }, serverInfo: { name: "nodedc_module_foundry", version: "0.6.0" },
instructions: "NDC Module Foundry edits application instances, provider-neutral map presentation profiles and approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.", instructions: "NDC Module Foundry edits application instances, provider-neutral map presentation profiles and approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
})); }));
} }

View File

@ -1,11 +1,11 @@
const MAP_LIVE_DATA_SLOT_KINDS = new Set(["entity-stream", "zone-stream"]); const MAP_LIVE_DATA_SLOT_KINDS = new Set(["entity-stream", "zone-stream", "subject-aspect-stream"]);
/** /**
* Map runtime bindings may only target slots that deliver canonical live * Map runtime bindings may only target slots that deliver canonical live
* entity facts. Point entities and zones have distinct template slots, but * entity facts. Point entities, zones and non-spatial selected-subject aspects
* share the same provider-neutral snapshot/patch transport contract. * have distinct template slots, but share the same provider-neutral
* snapshot/patch transport contract.
*/ */
export function isMapLiveDataSlot(slot) { export function isMapLiveDataSlot(slot) {
return Boolean(slot && MAP_LIVE_DATA_SLOT_KINDS.has(slot.kind)); return Boolean(slot && MAP_LIVE_DATA_SLOT_KINDS.has(slot.kind));
} }

View File

@ -2,9 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { isMapLiveDataSlot } from "./map-live-data-slot.mjs"; import { isMapLiveDataSlot } from "./map-live-data-slot.mjs";
test("point and zone streams are approved Map live-data slots", () => { test("point, zone and subject aspect streams are approved Map live-data slots", () => {
assert.equal(isMapLiveDataSlot({ id: "points", kind: "entity-stream" }), true); assert.equal(isMapLiveDataSlot({ id: "points", kind: "entity-stream" }), true);
assert.equal(isMapLiveDataSlot({ id: "zones", kind: "zone-stream" }), true); assert.equal(isMapLiveDataSlot({ id: "zones", kind: "zone-stream" }), true);
assert.equal(isMapLiveDataSlot({ id: "subject-details", kind: "subject-aspect-stream" }), true);
}); });
test("non-entity Map slots cannot receive a data-product binding", () => { test("non-entity Map slots cannot receive a data-product binding", () => {
@ -12,4 +13,3 @@ test("non-entity Map slots cannot receive a data-product binding", () => {
assert.equal(isMapLiveDataSlot(kind ? { id: "other", kind } : null), false); assert.equal(isMapLiveDataSlot(kind ? { id: "other", kind } : null), false);
} }
}); });

View File

@ -0,0 +1,163 @@
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
const PROFILE_KEYS = new Set(["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]);
const SECTION_KEYS = new Set(["id", "label", "fields"]);
const FIELD_KEYS = new Set(["id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds"]);
const SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
const FORMATS = new Set([
"text", "number", "timestamp", "boolean", "coordinate",
"signal_state", "movement_state", "telemetry_readings",
]);
const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]);
const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]);
const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]);
export function normalizeMapSubjectDetailProfile(value) {
object(value, "invalid_map_subject_detail_profile");
onlyKeys(value, PROFILE_KEYS, "invalid_map_subject_detail_profile_fields");
const id = identifier(value.id, "invalid_map_subject_detail_profile_id");
const version = text(value.version, 32, "invalid_map_subject_detail_profile_version");
if (!SEMVER.test(version)) fail("invalid_map_subject_detail_profile_version");
const title = safeLabel(value.title, 120, "invalid_map_subject_detail_profile_title");
const semanticTypes = identifiers(value.semanticTypes, 1, 8, "invalid_map_subject_detail_profile_semantic_types");
if (!Array.isArray(value.tabs) || value.tabs.length < 1 || value.tabs.length > 12) {
fail("invalid_map_subject_detail_profile_tabs");
}
const tabs = value.tabs.map(normalizeTab);
unique(tabs.map((tab) => tab.id), "duplicate_map_subject_detail_profile_tab_id");
const defaultTabId = identifier(value.defaultTabId, "invalid_map_subject_detail_profile_default_tab");
if (!tabs.some((tab) => tab.id === defaultTabId)) fail("map_subject_detail_profile_default_tab_not_found");
const fieldIds = tabs.flatMap((tab) => tab.sections.flatMap((section) => section.fields.map((field) => field.id)));
unique(fieldIds, "duplicate_map_subject_detail_profile_field_id");
return { id, version, title, semanticTypes, defaultTabId, tabs };
}
export function normalizeMapSubjectDetailProfiles(value) {
if (!Array.isArray(value) || value.length > 32) fail("invalid_map_subject_detail_profiles");
const profiles = value.map(normalizeMapSubjectDetailProfile);
unique(profiles.map((profile) => profile.id), "duplicate_map_subject_detail_profile_id");
return profiles;
}
function normalizeTab(value) {
object(value, "invalid_map_subject_detail_profile_tab");
onlyKeys(value, TAB_KEYS, "invalid_map_subject_detail_profile_tab_fields");
if (!Array.isArray(value.sections) || value.sections.length > 12) fail("invalid_map_subject_detail_profile_sections");
const sections = value.sections.map(normalizeSection);
unique(sections.map((section) => section.id), "duplicate_map_subject_detail_profile_section_id");
return {
id: identifier(value.id, "invalid_map_subject_detail_profile_tab_id"),
label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_tab_label"),
emptyMessage: safeLabel(value.emptyMessage, 240, "invalid_map_subject_detail_profile_empty_message"),
sections,
};
}
function normalizeSection(value) {
object(value, "invalid_map_subject_detail_profile_section");
onlyKeys(value, SECTION_KEYS, "invalid_map_subject_detail_profile_section_fields");
if (!Array.isArray(value.fields) || value.fields.length > 32) fail("invalid_map_subject_detail_profile_section_fields");
return {
id: identifier(value.id, "invalid_map_subject_detail_profile_section_id"),
label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_section_label"),
fields: value.fields.map(normalizeField),
};
}
function normalizeField(value) {
object(value, "invalid_map_subject_detail_profile_field");
onlyKeys(value, FIELD_KEYS, "invalid_map_subject_detail_profile_field_fields");
const source = text(value.source, 32, "invalid_map_subject_detail_profile_field_source");
const format = text(value.format, 32, "invalid_map_subject_detail_profile_field_format");
if (!SOURCES.has(source)) fail("invalid_map_subject_detail_profile_field_source");
if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format");
const field = sourceField(source, value.field);
if (source === "attribute" && SECRET_LIKE.test(field)) fail("map_subject_detail_profile_restricted_field");
const allowedReadingIds = value.allowedReadingIds === undefined
? []
: fields(value.allowedReadingIds, 0, 256, "invalid_map_subject_detail_profile_reading_ids");
if (allowedReadingIds.some((item) => SECRET_LIKE.test(item))) fail("map_subject_detail_profile_restricted_reading_id");
if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) {
fail("map_subject_detail_profile_readings_source_invalid");
}
if (format !== "telemetry_readings" && allowedReadingIds.length) {
fail("map_subject_detail_profile_reading_ids_not_allowed");
}
if (source === "geometry" && format !== "coordinate") fail("map_subject_detail_profile_geometry_format_invalid");
const unit = value.unit === undefined ? undefined : safeLabel(value.unit, 24, "invalid_map_subject_detail_profile_field_unit");
return {
id: identifier(value.id, "invalid_map_subject_detail_profile_field_id"),
aspectId: identifier(value.aspectId ?? "primary", "invalid_map_subject_detail_profile_aspect_id"),
source,
field,
label: safeLabel(value.label, 100, "invalid_map_subject_detail_profile_field_label"),
format,
...(unit ? { unit } : {}),
...(format === "telemetry_readings" ? { allowedReadingIds } : {}),
};
}
function sourceField(source, value) {
const normalized = text(value, 128, "invalid_map_subject_detail_profile_field_name");
if (source === "fact" && !FACT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_fact_field");
if (source === "geometry" && !GEOMETRY_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_geometry_field");
if (source === "context" && !CONTEXT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_context_field");
if (source === "attribute" && !FIELD.test(normalized)) fail("invalid_map_subject_detail_profile_attribute_field");
return normalized;
}
function onlyKeys(value, allowed, code) {
if (Object.keys(value).some((key) => !allowed.has(key))) fail(code);
}
function object(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
}
function identifier(value, code) {
const normalized = text(value, 160, code);
if (!IDENTIFIER.test(normalized)) fail(code);
return normalized;
}
function identifiers(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => identifier(item, code));
unique(normalized, code);
return normalized;
}
function fields(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => {
const result = text(item, 128, code);
if (!FIELD.test(result)) fail(code);
return result;
});
unique(normalized, code);
return normalized;
}
function safeLabel(value, max, code) {
const normalized = text(value, max, code);
if (SECRET_LIKE.test(normalized)) fail(code);
return normalized;
}
function text(value, max, code) {
if (typeof value !== "string") fail(code);
const normalized = value.trim();
if (!normalized || normalized.length > max) fail(code);
return normalized;
}
function unique(values, code) {
if (new Set(values).size !== values.length) fail(code);
}
function fail(code) {
throw Object.assign(new Error(code), { statusCode: 400 });
}

View File

@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { normalizeMapSubjectDetailProfile, normalizeMapSubjectDetailProfiles } from "./map-subject-detail-profile.mjs";
const registry = JSON.parse(await readFile(new URL("../registry/map-subject-detail-profiles.json", import.meta.url), "utf8"));
test("canonical subject detail profiles are provider-neutral and valid", () => {
const profiles = normalizeMapSubjectDetailProfiles(registry.profiles);
assert.equal(profiles.length, 1);
assert.equal(profiles[0].id, "map.subject-detail.operational.default");
assert.deepEqual(profiles[0].tabs.map((tab) => tab.id), [
"overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics",
]);
});
test("dynamic sensor readings are fail-closed until ids are explicitly classified", () => {
const profile = normalizeMapSubjectDetailProfile(registry.profiles[0]);
const field = profile.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0];
assert.equal(field.format, "telemetry_readings");
assert.deepEqual(field.allowedReadingIds, []);
});
test("restricted fields and reading ids cannot be introduced through MCP profiles", () => {
const restrictedField = structuredClone(registry.profiles[0]);
restrictedField.tabs[0].sections[0].fields[0].field = "api_key";
assert.throws(() => normalizeMapSubjectDetailProfile(restrictedField), /map_subject_detail_profile_restricted_field/);
const restrictedReading = structuredClone(registry.profiles[0]);
restrictedReading.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.imei"];
assert.throws(() => normalizeMapSubjectDetailProfile(restrictedReading), /map_subject_detail_profile_restricted_reading_id/);
});
test("profiles reject unknown schema fields and duplicate presentation ids", () => {
const unknown = structuredClone(registry.profiles[0]);
unknown.provider = "gelios";
assert.throws(() => normalizeMapSubjectDetailProfile(unknown), /invalid_map_subject_detail_profile_fields/);
const duplicate = structuredClone(registry.profiles[0]);
duplicate.tabs[1].sections[0].fields[0].id = duplicate.tabs[0].sections[0].fields[0].id;
assert.throws(() => normalizeMapSubjectDetailProfile(duplicate), /duplicate_map_subject_detail_profile_field_id/);
});