From dd2febe7cf2db8415cadcfd8846a8b0fe1e33c4e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 11:47:41 +0300 Subject: [PATCH] feat(map): open bounded telemetry card from entities --- apps/catalog/src/MapFixturePreview.tsx | 96 +++++++++++++- apps/catalog/src/mapSubjectCard.d.mts | 18 +++ apps/catalog/src/mapSubjectCard.mjs | 125 +++++++++++++++++++ apps/catalog/src/styles.css | 89 +++++++++++++ package.json | 3 +- registry/data-product-consumer-policies.json | 20 +++ scripts/map-subject-card.test.mjs | 99 +++++++++++++++ scripts/validate-registry.mjs | 6 + 8 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 apps/catalog/src/mapSubjectCard.d.mts create mode 100644 apps/catalog/src/mapSubjectCard.mjs create mode 100644 scripts/map-subject-card.test.mjs diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index d30fcb1..7d2e88d 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -31,6 +31,7 @@ import { findCameraSurveyPreset, type CameraSurveySelection, } from "./mapCameraPresets.js"; +import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs"; const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; @@ -255,6 +256,13 @@ const defaultLayersWindowRect: WorkspaceWindowRect = { height: 500, }; +const defaultSubjectCardRect: WorkspaceWindowRect = { + x: 940, + y: 52, + width: 390, + height: 560, +}; + function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) { const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state])); return Object.fromEntries(bindings.map((binding, index) => { @@ -307,6 +315,11 @@ export const MapFixturePreview = forwardRef(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { const workspaceRef = useRef(null); const [selectedId, setSelectedId] = useState(); + const [subjectCardOpen, setSubjectCardOpen] = useState(false); + const [subjectCardRect, setSubjectCardRect] = useState(defaultSubjectCardRect); + const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); + const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); + const [subjectCardActive, setSubjectCardActive] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false); const [layersWindowRect, setLayersWindowRect] = useState(defaultLayersWindowRect); @@ -381,6 +394,9 @@ export const MapFixturePreview = forwardRef entity.id === selectedId) ?? selectable[0]; + const selectedSubjectCard = useMemo(() => { + const entity = selectable.find((candidate) => candidate.id === selectedId); + return entity ? buildMapSubjectCardModel(entity.fact, { + title: entity.title, + bindingId: entity.bindingId, + dataProductId: entity.dataProductId, + }) : null; + }, [selectable, selectedId]); const presentation = useMemo( () => ({ ...mapSettings, cacheRefresh }), [cacheRefresh, mapSettings], @@ -670,12 +694,13 @@ export const MapFixturePreview = forwardRef { - const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; + const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: true, zIndex: nextZIndex }, })); setLayersWindowActive(false); + setSubjectCardActive(false); setActiveSubjectBindingId(bindingId); }; @@ -685,9 +710,10 @@ export const MapFixturePreview = forwardRef { - const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; + const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; setLayersWindowZIndex(nextZIndex); setLayersWindowActive(true); + setSubjectCardActive(false); setActiveSubjectBindingId(undefined); }; @@ -718,7 +744,12 @@ export const MapFixturePreview = forwardRef { if (!selectable.some((entity) => entity.id === entityId)) return; setSelectedId(entityId); - }, [selectable]); + setSubjectCardOpen(true); + setSubjectCardActive(true); + setLayersWindowActive(false); + setActiveSubjectBindingId(undefined); + setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1); + }, [layersWindowZIndex, selectable, subjectStates]); const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => { gatewayHealthRef.current = health; @@ -1312,6 +1343,65 @@ export const MapFixturePreview = forwardRef { + const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; + setSubjectCardZIndex(nextZIndex); + setSubjectCardActive(true); + setLayersWindowActive(false); + setActiveSubjectBindingId(undefined); + }} + onClose={() => { + setSubjectCardOpen(false); + setSubjectCardActive(false); + }} + title={selectedSubjectCard.title} + subtitle={selectedSubjectCard.sourceId} + active={subjectCardActive} + zIndex={subjectCardZIndex} + minWidth={320} + minHeight={320} + className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window" + aria-label={`Телеметрия: ${selectedSubjectCard.title}`} + > +
+ {selectedSubjectCard.sections.map((section) => ( +
+

{section.label}

+
+ {section.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ ))} +
+

Датчики

+ {selectedSubjectCard.readings.length ? ( +
+ {selectedSubjectCard.readings.map((reading) => ( +
+ {reading.label} + {reading.value} + {reading.observedAt ? : null} +
+ ))} +
+ ) : Gelios не передал разрешённых значений датчиков для этого объекта.} +
+
+ + ) : null} + {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} diff --git a/apps/catalog/src/mapSubjectCard.d.mts b/apps/catalog/src/mapSubjectCard.d.mts new file mode 100644 index 0000000..9d7f4ea --- /dev/null +++ b/apps/catalog/src/mapSubjectCard.d.mts @@ -0,0 +1,18 @@ +import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; + +export type MapSubjectCardRow = { key: string; label: string; value: string }; +export type MapSubjectCardSection = { id: string; label: string; rows: MapSubjectCardRow[] }; +export type MapTelemetryReading = { id: string; label: string; value: string; observedAt?: string }; +export type MapSubjectCardModel = { + title: string; + sourceId: string; + sections: MapSubjectCardSection[]; + readings: MapTelemetryReading[]; +}; + +export function buildMapSubjectCardModel( + fact: MapRuntimeFact, + context?: { title?: string; bindingId?: string; dataProductId?: string }, +): MapSubjectCardModel | null; + +export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[]; diff --git a/apps/catalog/src/mapSubjectCard.mjs b/apps/catalog/src/mapSubjectCard.mjs new file mode 100644 index 0000000..65d2464 --- /dev/null +++ b/apps/catalog/src/mapSubjectCard.mjs @@ -0,0 +1,125 @@ +const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i; + +const FIELD_PRESENTATION = Object.freeze({ + signal_state: { section: "state", label: "Связь" }, + movement_state: { section: "state", label: "Движение" }, + speed_kph: { section: "position", label: "Скорость", unit: "км/ч" }, + course_degrees: { section: "position", label: "Курс", unit: "°" }, + elevation_meters: { section: "position", label: "Высота", unit: "м" }, + satellite_count: { section: "quality", label: "Спутники" }, + hdop: { section: "quality", label: "HDOP" }, + horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" }, + mileage_km: { section: "operation", label: "Пробег", unit: "км" }, + engine_hours: { section: "operation", label: "Моточасы", unit: "ч" }, + object_kind: { section: "identity", label: "Класс объекта" }, + position_source: { section: "identity", label: "Источник позиции" }, +}); + +const SECTION_ORDER = Object.freeze([ + ["state", "Состояние"], + ["position", "Позиция"], + ["quality", "Качество GPS"], + ["operation", "Эксплуатация"], + ["identity", "Идентификация"], + ["other", "Прочие данные"], +]); + +export function buildMapSubjectCardModel(fact, context = {}) { + if (!fact || typeof fact !== "object") return null; + const attributes = isPlainObject(fact.attributes) ? fact.attributes : {}; + const rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }])); + const append = (section, key, label, value, unit) => { + if (value === undefined || SECRET_LIKE_KEY.test(key)) return; + rows.get(section)?.rows.push({ key, label, value: formatValue(key, value, unit) }); + }; + + append("identity", "source_id", "ID", fact.sourceId); + append("identity", "semantic_type", "Семантический тип", fact.semanticType); + append("identity", "data_product_id", "Продукт данных", context.dataProductId); + append("identity", "binding_id", "Binding", context.bindingId); + append("state", "presentation_status", "Статус отображения", fact.presentationStatus); + + if (fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) { + append("position", "latitude", "Широта", fact.geometry.coordinates[1]); + append("position", "longitude", "Долгота", fact.geometry.coordinates[0]); + } + append("state", "observed_at", "Данные объекта", fact.observedAt); + append("state", "received_at", "Получено NODE.DC", fact.receivedAt); + + for (const [key, value] of Object.entries(attributes)) { + if (key === "display_name" || key === "sensor_readings" || SECRET_LIKE_KEY.test(key)) continue; + const presentation = FIELD_PRESENTATION[key]; + append(presentation?.section ?? "other", key, presentation?.label ?? humanizeKey(key), value, presentation?.unit); + } + + const readings = normalizeTelemetryReadings(attributes.sensor_readings); + return { + title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект", + sourceId: stringValue(fact.sourceId), + sections: [...rows.values()].filter((section) => section.rows.length), + readings, + }; +} + +export function normalizeTelemetryReadings(value) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.slice(0, 128).flatMap((candidate) => { + if (!isPlainObject(candidate)) return []; + const id = stringValue(candidate.id); + const label = stringValue(candidate.label); + if (!/^[a-z][a-z0-9._:-]{1,127}$/.test(id) || !label || seen.has(id) || SECRET_LIKE_KEY.test(id)) return []; + if (!isScalar(candidate.value)) return []; + seen.add(id); + const unit = stringValue(candidate.unit).slice(0, 32); + const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined; + return [{ id, label: label.slice(0, 160), value: formatScalar(candidate.value, unit), observedAt }]; + }); +} + +function formatValue(key, value, unit) { + if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value); + if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit); + if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit); + if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", "); + if (isPlainObject(value)) return JSON.stringify(value); + return formatScalar(value, unit); +} + +function formatScalar(value, unit = "") { + if (typeof value === "boolean") return value ? "Да" : "Нет"; + if (typeof value === "number") { + const number = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 3 }).format(value); + return unit ? `${number} ${unit}` : number; + } + const text = stringValue(value); + return unit && text ? `${text} ${unit}` : text || "—"; +} + +function formatTimestamp(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); +} + +function humanizeKey(value) { + const text = String(value).replace(/[._-]+/g, " ").trim(); + return text ? text[0].toUpperCase() + text.slice(1) : String(value); +} + +function isScalar(value) { + return typeof value === "string" + || typeof value === "boolean" + || (typeof value === "number" && Number.isFinite(value)); +} + +function stringValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function isIsoTimestamp(value) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index a3d8ace..e6f608b 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -808,6 +808,95 @@ textarea { padding: 0.42rem 0.65rem 0.68rem; } +.catalog-map-fixture__subject-card { + --nodedc-radius-modal: 1.45rem; +} + +.catalog-map-fixture__subject-card .nodedc-workspace-window__body { + overflow: auto; + padding: 0.35rem 0.65rem 0.75rem; +} + +.catalog-map-subject-card { + display: grid; + gap: 0.65rem; + padding-bottom: 0.2rem; +} + +.catalog-map-subject-card__section { + display: grid; + gap: 0.36rem; + border-radius: var(--nodedc-radius-control); + background: rgba(255, 255, 255, 0.46); + padding: 0.72rem 0.78rem; +} + +.catalog-map-subject-card__section h3, +.catalog-map-subject-card__section dl, +.catalog-map-subject-card__section dt, +.catalog-map-subject-card__section dd { + margin: 0; +} + +.catalog-map-subject-card__section h3 { + color: var(--nodedc-text-primary); + font-size: var(--nodedc-font-size-xs); + font-weight: 820; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.catalog-map-subject-card__section dl, +.catalog-map-subject-card__readings { + display: grid; + gap: 0.14rem; +} + +.catalog-map-subject-card__row, +.catalog-map-subject-card__reading { + display: grid; + min-width: 0; + grid-template-columns: minmax(7.5rem, 0.85fr) minmax(0, 1.15fr); + align-items: baseline; + gap: 0.6rem; + border-top: 1px solid rgba(13, 14, 17, 0.08); + padding: 0.34rem 0; +} + +.catalog-map-subject-card__row:first-child, +.catalog-map-subject-card__reading:first-child { + border-top: 0; +} + +.catalog-map-subject-card__row dt, +.catalog-map-subject-card__reading span { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + line-height: 1.35; +} + +.catalog-map-subject-card__row dd, +.catalog-map-subject-card__reading strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--nodedc-text-primary); + font-size: var(--nodedc-font-size-sm); + font-weight: 760; + line-height: 1.35; +} + +.catalog-map-subject-card__reading time { + grid-column: 2; + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.catalog-map-subject-card__section > small { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + line-height: 1.45; +} + .catalog-map-fixture__target-filters, .catalog-map-fixture__target-filters section { display: grid; diff --git a/package.json b/package.json index d6e82cf..0e1be4f 100644 --- a/package.json +++ b/package.json @@ -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", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-subject-card", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -24,6 +24,7 @@ "test:map-filters": "node --test scripts/map-presentation-filters.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-subject-card": "node --test scripts/map-subject-card.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" }, diff --git a/registry/data-product-consumer-policies.json b/registry/data-product-consumer-policies.json index 29f6ee1..7fcb36a 100644 --- a/registry/data-product-consumer-policies.json +++ b/registry/data-product-consumer-policies.json @@ -48,6 +48,26 @@ }, "removeMode": "canonical-tombstone-or-snapshot-rebase" }, + { + "id": "map-moving-object-current-v5", + "version": "5.0.0", + "dataProductId": "fleet.positions.current.v5", + "productVersion": "5.0.0", + "staleAfterMs": null, + "terminalStatuses": [ + "inactive" + ], + "statusContract": { + "attribute": "signal_state", + "allowedValues": [ + "active", + "inactive" + ], + "missing": "reject", + "freshness": "none" + }, + "removeMode": "canonical-tombstone-or-snapshot-rebase" + }, { "id": "map-zone-current-v1", "version": "1.0.0", diff --git a/scripts/map-subject-card.test.mjs b/scripts/map-subject-card.test.mjs new file mode 100644 index 0000000..9b8c837 --- /dev/null +++ b/scripts/map-subject-card.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { buildMapSubjectCardModel, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs"; + +const fact = { + sourceId: "867236078012345", + semanticType: "map.moving_object", + presentationStatus: "current", + observedAt: "2026-07-22T08:42:03.000Z", + receivedAt: "2026-07-22T08:42:05.000Z", + geometry: { type: "Point", coordinates: [37.6176, 55.7558] }, + attributes: { + display_name: "555590 B2 867236078012345", + signal_state: "active", + movement_state: "moving", + speed_kph: 17.4, + course_degrees: 211, + elevation_meters: 142, + satellite_count: 12, + hdop: 0.7, + horizontal_accuracy_meters: 3.5, + mileage_km: 4832.25, + engine_hours: 912.5, + object_kind: "trike", + position_source: "gelios", + sensor_readings: [ + { id: "sensor.temperature", label: "Температура", value: 21.5, unit: "°C", observedAt: "2026-07-22T08:42:02.000Z" }, + ], + }, +}; + +test("subject card exposes the full provider-neutral telemetry projection", () => { + const card = buildMapSubjectCardModel(fact, { + bindingId: "trike-current-positions", + dataProductId: "fleet.positions.current.v5", + }); + + assert.ok(card); + assert.equal(card.title, "555590 B2 867236078012345"); + assert.equal(card.sourceId, fact.sourceId); + const values = new Map(card.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))); + assert.equal(values.get("source_id"), fact.sourceId); + assert.equal(values.get("data_product_id"), "fleet.positions.current.v5"); + assert.equal(values.get("binding_id"), "trike-current-positions"); + assert.equal(values.get("latitude"), "55,756"); + assert.equal(values.get("longitude"), "37,618"); + assert.equal(values.get("signal_state"), "На связи"); + assert.equal(values.get("movement_state"), "В движении"); + assert.equal(values.get("speed_kph"), "17,4 км/ч"); + assert.equal(values.get("mileage_km"), "4 832,25 км"); + assert.equal(values.get("engine_hours"), "912,5 ч"); + assert.equal(card.readings[0]?.value, "21,5 °C"); +}); + +test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => { + const readings = normalizeTelemetryReadings([ + { id: "sensor.voltage", label: "Напряжение", value: 48.2, unit: "V" }, + { id: "sensor.voltage", label: "Повтор", value: 49 }, + { id: "sensor.imei", label: "IMEI", value: "forbidden" }, + { id: "sensor.payload", label: "Payload", value: { nested: true } }, + { id: "Sensor Bad", label: "Bad id", value: 1 }, + ]); + + assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]); +}); + +test("card model never renders provider secrets or direct identifiers from attributes", () => { + const card = buildMapSubjectCardModel({ + ...fact, + attributes: { + ...fact.attributes, + api_key: "forbidden", + imei: "forbidden", + phone: "+70000000000", + raw_params: "forbidden", + }, + }); + const keys = card.sections.flatMap((section) => section.rows.map((row) => row.key)); + assert.ok(!keys.includes("api_key")); + assert.ok(!keys.includes("imei")); + assert.ok(!keys.includes("phone")); + assert.ok(!keys.includes("raw_params")); +}); + +test("Cesium pin and label share one entity selection that opens the subject card", async () => { + const [renderer, preview] = await Promise.all([ + readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), + ]); + + assert.match(renderer, /viewer\?\.scene\.pick/); + assert.match(renderer, /pickedId instanceof Entity/); + assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/); + assert.match(preview, /const handleSelect = useCallback/); + assert.match(preview, /setSubjectCardOpen\(true\)/); + assert.match(preview, /title=\{selectedSubjectCard\.title\}/); + assert.match(preview, /Телеметрия:/); +}); diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 7f328bd..33e1f39 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -110,6 +110,12 @@ requireValue(movingObjectV4Policy?.statusContract?.attribute === "signal_state", requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive"); requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states"); +const movingObjectV5Policy = consumerPolicies.find((policy) => policy.dataProductId === "fleet.positions.current.v5" && policy.productVersion === "5.0.0"); +requireValue(movingObjectV5Policy?.id === "map-moving-object-current-v5", "fleet.positions.current.v5 consumer policy is missing"); +requireValue(movingObjectV5Policy?.statusContract?.attribute === "signal_state", "fleet.positions.current.v5 status source must be signal_state"); +requireValue(JSON.stringify(movingObjectV5Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v5 signal states must remain closed active/inactive"); +requireValue(movingObjectV5Policy?.statusContract?.freshness === "none" && movingObjectV5Policy?.staleAfterMs === null, "fleet.positions.current.v5 must not invent freshness states"); + const zoneV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v1" && policy.productVersion === "1.0.0"); requireValue(JSON.stringify(zoneV1Policy) === JSON.stringify({ id: "map-zone-current-v1",