feat(foundry): add composable subject detail profiles
This commit is contained in:
parent
dd2febe7cf
commit
dc82ae4c07
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
CameraSpiralState,
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} 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 })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
|
|
@ -112,6 +112,36 @@ export type MapDataProductBinding = {
|
|||
semanticTypes: string[];
|
||||
fieldProjection: 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 = {
|
||||
|
|
@ -137,6 +167,7 @@ export type MapPageLayout = {
|
|||
camera: MapCameraView;
|
||||
pinBindings: MapPinBinding[];
|
||||
presentationProfiles: MapPresentationProfile[];
|
||||
subjectDetailProfiles: MapSubjectDetailProfile[];
|
||||
dataProductBindings: MapDataProductBinding[];
|
||||
subjectStates: MapSubjectState[];
|
||||
savedAt?: string;
|
||||
|
|
@ -216,6 +247,7 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
|
|||
camera: { ...fallbackMapCamera },
|
||||
pinBindings: [],
|
||||
presentationProfiles: [],
|
||||
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
|
|
@ -320,6 +352,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
|
||||
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
|
||||
const [subjectCardActive, setSubjectCardActive] = useState(false);
|
||||
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
|
||||
|
|
@ -356,6 +389,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
|
||||
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
|
||||
// not belong to the visual inspector. Preserve them verbatim when a human
|
||||
// edits camera or presentation settings and saves the page layout.
|
||||
|
|
@ -379,6 +417,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
const selectable = useMemo(() => (
|
||||
runtimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
if (bindingConfig?.joinToBindingId) return [];
|
||||
const facts = [...binding.facts];
|
||||
const primaryProfile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
|
|
@ -476,12 +515,33 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
||||
const selectedSubjectCard = useMemo(() => {
|
||||
const entity = selectable.find((candidate) => candidate.id === selectedId);
|
||||
return entity ? buildMapSubjectCardModel(entity.fact, {
|
||||
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,
|
||||
bindingId: entity.bindingId,
|
||||
dataProductId: entity.dataProductId,
|
||||
}) : null;
|
||||
}, [selectable, selectedId]);
|
||||
profile,
|
||||
aspects,
|
||||
});
|
||||
}, [dataProductBindings, runtimeBindings, selectable, selectedId, subjectDetailProfiles]);
|
||||
const presentation = useMemo<MapPresentation>(
|
||||
() => ({ ...mapSettings, cacheRefresh }),
|
||||
[cacheRefresh, mapSettings],
|
||||
|
|
@ -650,6 +710,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||
pinBindings,
|
||||
presentationProfiles,
|
||||
subjectDetailProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
|
||||
bindingId: binding.id,
|
||||
|
|
@ -663,7 +724,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
: 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) => {
|
||||
setSubjectStates((current) => {
|
||||
|
|
@ -744,12 +805,17 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
const handleSelect = useCallback((entityId: string) => {
|
||||
if (!selectable.some((entity) => entity.id === entityId)) return;
|
||||
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);
|
||||
setSubjectCardActive(true);
|
||||
setLayersWindowActive(false);
|
||||
setActiveSubjectBindingId(undefined);
|
||||
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) => {
|
||||
gatewayHealthRef.current = health;
|
||||
|
|
@ -1368,36 +1434,52 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
minWidth={320}
|
||||
minHeight={320}
|
||||
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">
|
||||
{selectedSubjectCard.sections.map((section) => (
|
||||
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${section.id}`}>
|
||||
<h3 id={`subject-card-${section.id}`}>{section.label}</h3>
|
||||
<dl>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.key} className="catalog-map-subject-card__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<div className="catalog-map-subject-card__tabs">
|
||||
<SegmentedControl
|
||||
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>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.key} className="catalog-map-subject-card__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{section.readings.length ? (
|
||||
<div className="catalog-map-subject-card__readings">
|
||||
{section.readings.map((reading) => (
|
||||
<div className="catalog-map-subject-card__reading" key={reading.id}>
|
||||
<span>{reading.label}</span>
|
||||
<strong>{reading.value}</strong>
|
||||
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<section className="catalog-map-subject-card__section" aria-labelledby="subject-card-readings">
|
||||
<h3 id="subject-card-readings">Датчики</h3>
|
||||
{selectedSubjectCard.readings.length ? (
|
||||
<div className="catalog-map-subject-card__readings">
|
||||
{selectedSubjectCard.readings.map((reading) => (
|
||||
<div className="catalog-map-subject-card__reading" key={reading.id}>
|
||||
<span>{reading.label}</span>
|
||||
<strong>{reading.value}</strong>
|
||||
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <small>Gelios не передал разрешённых значений датчиков для этого объекта.</small>}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceWindow>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,64 @@
|
|||
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 MapSubjectCardSection = { id: string; label: string; rows: MapSubjectCardRow[] };
|
||||
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 = {
|
||||
title: string;
|
||||
sourceId: string;
|
||||
sections: MapSubjectCardSection[];
|
||||
readings: MapTelemetryReading[];
|
||||
profileId: string;
|
||||
defaultTabId: string;
|
||||
tabs: MapSubjectCardTab[];
|
||||
};
|
||||
|
||||
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE: Readonly<MapSubjectDetailProfile>;
|
||||
|
||||
export function buildMapSubjectCardModel(
|
||||
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;
|
||||
|
||||
export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[];
|
||||
export function normalizeTelemetryReadings(value: unknown, allowedReadingIds?: string[]): MapTelemetryReading[];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
signal_state: { section: "state", label: "Связь" },
|
||||
movement_state: { section: "state", label: "Движение" },
|
||||
speed_kph: { section: "position", label: "Скорость", unit: "км/ч" },
|
||||
course_degrees: { section: "position", label: "Курс", unit: "°" },
|
||||
elevation_meters: { section: "position", label: "Высота", unit: "м" },
|
||||
satellite_count: { section: "quality", label: "Спутники" },
|
||||
hdop: { section: "quality", label: "HDOP" },
|
||||
horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" },
|
||||
mileage_km: { section: "operation", label: "Пробег", unit: "км" },
|
||||
engine_hours: { section: "operation", label: "Моточасы", unit: "ч" },
|
||||
object_kind: { section: "identity", label: "Класс объекта" },
|
||||
position_source: { section: "identity", label: "Источник позиции" },
|
||||
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({
|
||||
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", 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 = {}) {
|
||||
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 rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }]));
|
||||
const append = (section, key, label, value, unit) => {
|
||||
if (value === undefined || SECRET_LIKE_KEY.test(key)) return;
|
||||
rows.get(section)?.rows.push({ key, label, value: formatValue(key, value, unit) });
|
||||
};
|
||||
|
||||
append("identity", "source_id", "ID", fact.sourceId);
|
||||
append("identity", "semantic_type", "Семантический тип", fact.semanticType);
|
||||
append("identity", "data_product_id", "Продукт данных", context.dataProductId);
|
||||
append("identity", "binding_id", "Binding", context.bindingId);
|
||||
append("state", "presentation_status", "Статус отображения", fact.presentationStatus);
|
||||
|
||||
if (fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) {
|
||||
append("position", "latitude", "Широта", fact.geometry.coordinates[1]);
|
||||
append("position", "longitude", "Долгота", fact.geometry.coordinates[0]);
|
||||
}
|
||||
append("state", "observed_at", "Данные объекта", fact.observedAt);
|
||||
append("state", "received_at", "Получено NODE.DC", fact.receivedAt);
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === "display_name" || key === "sensor_readings" || SECRET_LIKE_KEY.test(key)) continue;
|
||||
const presentation = FIELD_PRESENTATION[key];
|
||||
append(presentation?.section ?? "other", key, presentation?.label ?? humanizeKey(key), value, presentation?.unit);
|
||||
}
|
||||
|
||||
const readings = normalizeTelemetryReadings(attributes.sensor_readings);
|
||||
const defaultTabId = tabs.some((tab) => tab.id === profile.defaultTabId) ? profile.defaultTabId : tabs[0].id;
|
||||
return {
|
||||
title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект",
|
||||
sourceId: stringValue(fact.sourceId),
|
||||
sections: [...rows.values()].filter((section) => section.rows.length),
|
||||
readings,
|
||||
profileId: stringValue(profile.id),
|
||||
defaultTabId,
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelemetryReadings(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
function buildTab(tab, aspects) {
|
||||
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();
|
||||
return value.slice(0, 128).flatMap((candidate) => {
|
||||
if (!isPlainObject(candidate)) return [];
|
||||
const id = stringValue(candidate.id);
|
||||
const label = stringValue(candidate.label);
|
||||
if (!/^[a-z][a-z0-9._:-]{1,127}$/.test(id) || !label || seen.has(id) || SECRET_LIKE_KEY.test(id)) return [];
|
||||
if (!isScalar(candidate.value)) return [];
|
||||
if (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return [];
|
||||
if (!isSafeScalar(candidate.value)) return [];
|
||||
seen.add(id);
|
||||
const unit = stringValue(candidate.unit).slice(0, 32);
|
||||
const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined;
|
||||
|
|
@ -77,12 +186,14 @@ export function normalizeTelemetryReadings(value) {
|
|||
});
|
||||
}
|
||||
|
||||
function formatValue(key, value, unit) {
|
||||
if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value);
|
||||
if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
|
||||
if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
||||
if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", ");
|
||||
if (isPlainObject(value)) return JSON.stringify(value);
|
||||
function formatValue(format, value, unit) {
|
||||
if (format === "timestamp") return isIsoTimestamp(value) ? formatTimestamp(value) : "—";
|
||||
if (format === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
|
||||
if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
||||
if (format === "boolean") return Boolean(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);
|
||||
}
|
||||
|
||||
|
|
@ -101,15 +212,16 @@ function formatTimestamp(value) {
|
|||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function humanizeKey(value) {
|
||||
const text = String(value).replace(/[._-]+/g, " ").trim();
|
||||
return text ? text[0].toUpperCase() + text.slice(1) : String(value);
|
||||
function isSafeScalar(value) {
|
||||
if (typeof value === "boolean") return true;
|
||||
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) {
|
||||
return typeof value === "string"
|
||||
|| typeof value === "boolean"
|
||||
|| (typeof value === "number" && Number.isFinite(value));
|
||||
function safeIdentifier(value) {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9._:-]{1,159}$/.test(value);
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
|
|
|
|||
|
|
@ -823,6 +823,35 @@ textarea {
|
|||
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 {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,22 @@ profile; renderer не содержит provider-specific условий.
|
|||
|
||||
Кнопка `Объекты` открывает вверх текстовый 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 не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
|
||||
|
||||
## Acceptance fixtures
|
||||
|
|
@ -98,8 +114,10 @@ Runtime tile cache не является исходным кодом и не х
|
|||
## Live data-product runtime
|
||||
|
||||
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
|
||||
semantic types, field projection, `presentationProfileId` и `slotId`: `points`
|
||||
для live point entities или `zones` для polygon/multipolygon `map.zone`.
|
||||
semantic types, field projection, `presentationProfileId`, optional
|
||||
`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:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ Foundry предоставляет базовый MCP-контур для уже
|
|||
|
||||
- `Page Library` и канонические шаблоны доступны только на чтение;
|
||||
- изменяются только экземпляры в `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 намеренно отсутствует;
|
||||
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"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: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",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.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-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:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
{ "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": "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": "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 }
|
||||
|
|
|
|||
|
|
@ -55,5 +55,6 @@
|
|||
"mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json",
|
||||
"mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,27 @@
|
|||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"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" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { buildMapSubjectCardModel, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
|
||||
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
|
||||
|
||||
const fact = {
|
||||
sourceId: "867236078012345",
|
||||
sourceId: "gelios-unit-555590",
|
||||
semanticType: "map.moving_object",
|
||||
presentationStatus: "current",
|
||||
observedAt: "2026-07-22T08:42:03.000Z",
|
||||
|
|
@ -31,26 +31,32 @@ const fact = {
|
|||
};
|
||||
|
||||
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, {
|
||||
bindingId: "trike-current-positions",
|
||||
dataProductId: "fleet.positions.current.v5",
|
||||
profile,
|
||||
});
|
||||
|
||||
assert.ok(card);
|
||||
assert.equal(card.title, "555590 B2 867236078012345");
|
||||
assert.equal(card.sourceId, fact.sourceId);
|
||||
const values = new Map(card.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value])));
|
||||
assert.equal(values.get("source_id"), fact.sourceId);
|
||||
assert.equal(values.get("data_product_id"), "fleet.positions.current.v5");
|
||||
assert.equal(values.get("binding_id"), "trike-current-positions");
|
||||
assert.equal(values.get("latitude"), "55,756");
|
||||
assert.equal(values.get("longitude"), "37,618");
|
||||
assert.equal(values.get("signal_state"), "На связи");
|
||||
assert.equal(values.get("movement_state"), "В движении");
|
||||
assert.equal(values.get("speed_kph"), "17,4 км/ч");
|
||||
assert.equal(values.get("mileage_km"), "4 832,25 км");
|
||||
assert.equal(values.get("engine_hours"), "912,5 ч");
|
||||
assert.equal(card.readings[0]?.value, "21,5 °C");
|
||||
assert.equal(card.defaultTabId, "overview");
|
||||
assert.deepEqual(card.tabs.map((tab) => tab.id), ["overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics"]);
|
||||
const values = new Map(card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))));
|
||||
assert.equal(values.get("diagnostics.source-id"), fact.sourceId);
|
||||
assert.equal(values.get("diagnostics.product"), "fleet.positions.current.v5");
|
||||
assert.equal(values.get("position.latitude"), "55,75580");
|
||||
assert.equal(values.get("position.longitude"), "37,61760");
|
||||
assert.equal(values.get("overview.signal"), "На связи");
|
||||
assert.equal(values.get("overview.movement"), "В движении");
|
||||
assert.equal(values.get("overview.speed"), "17,4 км/ч");
|
||||
assert.equal(values.get("counters.mileage"), "4 832,25 км");
|
||||
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", () => {
|
||||
|
|
@ -60,7 +66,8 @@ test("sensor readings remain bounded and reject duplicate, complex and restricte
|
|||
{ id: "sensor.imei", label: "IMEI", value: "forbidden" },
|
||||
{ id: "sensor.payload", label: "Payload", value: { nested: true } },
|
||||
{ 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 }]);
|
||||
});
|
||||
|
|
@ -76,13 +83,55 @@ test("card model never renders provider secrets or direct identifiers from attri
|
|||
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("imei"));
|
||||
assert.ok(!keys.includes("phone"));
|
||||
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 () => {
|
||||
const [renderer, preview] = await Promise.all([
|
||||
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, /setSubjectCardOpen\(true\)/);
|
||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
||||
assert.match(preview, /Телеметрия:/);
|
||||
assert.match(preview, /Карточка объекта:/);
|
||||
assert.match(preview, /SegmentedControl/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -323,12 +323,13 @@ try {
|
|||
return payload.result;
|
||||
};
|
||||
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");
|
||||
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_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_upsert_map_subject_detail_profile"));
|
||||
|
||||
const workloadHeaders = {
|
||||
authorization: `Bearer ${bindingGrantToken}`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeMapSubjectDetailProfiles } from "../server/map-subject-detail-profile.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
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 mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.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 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.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.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(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(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(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(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing");
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-prov
|
|||
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
||||
import { isMapLiveDataSlot } from "./map-live-data-slot.mjs";
|
||||
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
||||
import { normalizeMapSubjectDetailProfile, normalizeMapSubjectDetailProfiles } from "./map-subject-detail-profile.mjs";
|
||||
import { createFoundryAuth } from "./nodedc-auth.mjs";
|
||||
|
||||
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");
|
||||
}
|
||||
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 mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
||||
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
|
||||
|
|
@ -333,6 +339,15 @@ function validateMapDataProductBinding(value) {
|
|||
const presentationProfileId = value.presentationProfileId === undefined
|
||||
? null
|
||||
: 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(dataProductId)) throw applicationError("invalid_map_data_product_id");
|
||||
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)) {
|
||||
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))) {
|
||||
throw applicationError("map_data_product_binding_contains_transport");
|
||||
}
|
||||
|
|
@ -358,6 +380,9 @@ function validateMapDataProductBinding(value) {
|
|||
...(displayName ? { displayName } : {}),
|
||||
...(order !== null ? { order } : {}),
|
||||
...(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}`);
|
||||
}
|
||||
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 subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
|
||||
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
|
||||
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
|
||||
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 {
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
|
|
@ -491,6 +543,7 @@ function validateMapPageLayout(value) {
|
|||
},
|
||||
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
|
||||
presentationProfiles,
|
||||
subjectDetailProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates,
|
||||
};
|
||||
|
|
@ -582,6 +635,7 @@ function defaultMapPageLayout() {
|
|||
},
|
||||
pinBindings: [],
|
||||
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
|
||||
subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles),
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
|
|
@ -1806,7 +1860,7 @@ const foundryMcpOperations = {
|
|||
const config = foundryMcpConfig();
|
||||
return {
|
||||
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",
|
||||
canonicalPageLibrary: "read-only",
|
||||
applicationInstances: "read-write",
|
||||
|
|
@ -1819,6 +1873,7 @@ const foundryMcpOperations = {
|
|||
"map-pin.upsert",
|
||||
"map-pin.remove",
|
||||
"map-presentation-profile.upsert",
|
||||
"map-subject-detail-profile.upsert",
|
||||
"map-page-settings.update",
|
||||
"map-page-view-state.save",
|
||||
"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) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_update_map_page_settings",
|
||||
|
|
|
|||
|
|
@ -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(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"]);
|
||||
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");
|
||||
assert.ok(mapSettingsTool);
|
||||
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");
|
||||
assert.equal(bindingTool.inputSchema.properties.binding.properties.displayName.maxLength, 120);
|
||||
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, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
type: "object",
|
||||
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",
|
||||
title: "Update Application Map settings",
|
||||
|
|
@ -629,6 +712,9 @@ const tools = [
|
|||
semanticTypes: { type: "array", items: { type: "string" } },
|
||||
fieldProjection: { type: "array", items: { type: "string" } },
|
||||
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_remove_map_pin_binding: (input, actor) => operations.removeMapPinBinding(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_save_map_page_view_state: (input, actor) => operations.saveMapPageViewState(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, {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
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.",
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* entity facts. Point entities and zones have distinct template slots, but
|
||||
* share the same provider-neutral snapshot/patch transport contract.
|
||||
* entity facts. Point entities, zones and non-spatial selected-subject aspects
|
||||
* have distinct template slots, but share the same provider-neutral
|
||||
* snapshot/patch transport contract.
|
||||
*/
|
||||
export function isMapLiveDataSlot(slot) {
|
||||
return Boolean(slot && MAP_LIVE_DATA_SLOT_KINDS.has(slot.kind));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import assert from "node:assert/strict";
|
|||
import test from "node:test";
|
||||
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: "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", () => {
|
||||
|
|
@ -12,4 +13,3 @@ test("non-entity Map slots cannot receive a data-product binding", () => {
|
|||
assert.equal(isMapLiveDataSlot(kind ? { id: "other", kind } : null), false);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
Loading…
Reference in New Issue