From c859ae4db07f70b9ec5e2e3a2ddc8fa139eee1e0 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 11:43:20 +0300 Subject: [PATCH] feat(map): render classified unit identity aspects --- apps/catalog/src/MapFixturePreview.tsx | 2 +- apps/catalog/src/mapSubjectCard.d.mts | 10 +- apps/catalog/src/mapSubjectCard.mjs | 9 ++ registry/data-product-consumer-policies.json | 80 +++++++++++++ scripts/map-subject-card.test.mjs | 75 ++++++++++++ scripts/validate-registry.mjs | 2 +- server/foundry-data-product-consumer.mjs | 13 +- server/foundry-data-product-consumer.test.mjs | 111 ++++++++++++++++++ server/foundry-mcp.mjs | 2 +- server/map-subject-detail-profile.mjs | 5 +- server/map-subject-detail-profile.test.mjs | 18 +++ 11 files changed, 320 insertions(+), 7 deletions(-) diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 13074fa..9fe5362 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -137,7 +137,7 @@ export type MapSubjectDetailProfile = { source: "fact" | "attribute" | "geometry" | "context"; field: string; label: string; - format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings"; + format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "string_list" | "telemetry_readings"; unit?: string; allowedReadingIds?: string[]; }>; diff --git a/apps/catalog/src/mapSubjectCard.d.mts b/apps/catalog/src/mapSubjectCard.d.mts index d844d96..49ce7eb 100644 --- a/apps/catalog/src/mapSubjectCard.d.mts +++ b/apps/catalog/src/mapSubjectCard.d.mts @@ -6,7 +6,8 @@ export type MapSubjectDetailField = { source: "fact" | "attribute" | "geometry" | "context"; field: string; label: string; - format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings"; + format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "string_list" | "telemetry_readings"; + dataClass?: "operational" | "restricted"; unit?: string; allowedReadingIds?: string[]; }; @@ -57,7 +58,12 @@ export function buildMapSubjectCardModel( bindingId?: string; dataProductId?: string; profile?: MapSubjectDetailProfile; - aspects?: Record; + aspects?: Record; }, ): MapSubjectCardModel | null; diff --git a/apps/catalog/src/mapSubjectCard.mjs b/apps/catalog/src/mapSubjectCard.mjs index 7d2f595..82fb740 100644 --- a/apps/catalog/src/mapSubjectCard.mjs +++ b/apps/catalog/src/mapSubjectCard.mjs @@ -160,6 +160,7 @@ function buildSection(section, aspects) { readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds)); continue; } + if (field.format === "string_list" && (!Array.isArray(value) || value.length === 0)) continue; if (value === undefined || value === null || value === "") continue; if (typeof value === "string" && DIRECT_IDENTIFIER_VALUE.test(value.trim()) && !restricted) continue; rows.push({ @@ -214,6 +215,14 @@ function formatValue(format, value, unit) { 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 === "string_list") { + if (!Array.isArray(value)) return "—"; + const items = value.slice(0, 256).flatMap((item) => { + const scalar = formatScalar(item); + return scalar === "—" ? [] : [scalar.replace(/^([^=]{1,160})=/, "$1: ")]; + }); + return items.length ? items.join(" • ") : "—"; + } if (format === "coordinate" && typeof value === "number" && Number.isFinite(value)) { return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value); } diff --git a/registry/data-product-consumer-policies.json b/registry/data-product-consumer-policies.json index 03488c1..9d2f1dc 100644 --- a/registry/data-product-consumer-policies.json +++ b/registry/data-product-consumer-policies.json @@ -185,6 +185,86 @@ }, "removeMode": "canonical-tombstone-or-snapshot-rebase" }, + { + "id": "map-moving-object-unit-identity-current-v1", + "version": "1.0.0", + "dataProductId": "fleet.units.identity.current.v1", + "productVersion": "1.0.0", + "dataClass": "restricted", + "freshness": "none", + "staleAfterMs": null, + "terminalStatuses": [], + "consumerContract": { + "ontologyRevision": "ontology.map.moving_object.v3", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fieldProjection": [ + "asset_brand", + "asset_color", + "asset_model", + "asset_number_plate", + "asset_vin", + "asset_year", + "assigned_driver_name", + "assigned_driver_phone", + "available_user_ids", + "available_user_logins", + "custom_fields", + "device_imei", + "device_phone_primary", + "device_phone_secondary", + "display_name", + "hardware_manufacturer_id", + "hardware_manufacturer_name", + "hardware_port", + "hardware_type_class", + "hardware_type_id", + "hardware_type_name", + "provider_creator_id", + "provider_creator_login", + "provider_unit_id", + "unit_type_class", + "unit_type_id", + "unit_type_name" + ], + "fieldTypes": { + "asset_brand": "string", + "asset_color": "string", + "asset_model": "string", + "asset_number_plate": "string", + "asset_vin": "string", + "asset_year": "number", + "assigned_driver_name": "string", + "assigned_driver_phone": "string", + "available_user_ids": "string_array", + "available_user_logins": "string_array", + "custom_fields": "string_array", + "device_imei": "string", + "device_phone_primary": "string", + "device_phone_secondary": "string", + "display_name": "string", + "hardware_manufacturer_id": "string", + "hardware_manufacturer_name": "string", + "hardware_port": "number", + "hardware_type_class": "string", + "hardware_type_id": "string", + "hardware_type_name": "string", + "provider_creator_id": "string", + "provider_creator_login": "string", + "provider_unit_id": "string", + "unit_type_class": "string", + "unit_type_id": "string", + "unit_type_name": "string" + }, + "geometryTypes": [], + "subjectIdentity": "semantic-type+source-id", + "snapshotMode": "atomic-replace", + "patchMode": "atomic" + }, + "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 index 57ece54..ff469fd 100644 --- a/scripts/map-subject-card.test.mjs +++ b/scripts/map-subject-card.test.mjs @@ -199,6 +199,81 @@ test("restricted joined aspects render declared identifiers and remain fail-clos assert.equal(rejected.tabs[0].empty, true); }); +test("restricted identity aspects render full identifiers and bounded string lists", () => { + const profile = { + id: "map.subject-detail.identity", + version: "1.0.0", + title: "Identity", + semanticTypes: ["map.moving_object"], + defaultTabId: "identity", + tabs: [{ + id: "identity", + label: "Идентификация", + emptyMessage: "Нет данных", + sections: [{ + id: "device", + label: "Устройство и доступ", + fields: [ + { + id: "identity.imei", + aspectId: "identity", + source: "attribute", + field: "device_imei", + label: "IMEI", + format: "text", + dataClass: "restricted", + }, + { + id: "identity.users", + aspectId: "identity", + source: "attribute", + field: "available_user_logins", + label: "Доступно пользователям", + format: "string_list", + dataClass: "restricted", + }, + { + id: "identity.custom", + aspectId: "identity", + source: "attribute", + field: "custom_fields", + label: "Пользовательские поля", + format: "string_list", + dataClass: "restricted", + }, + ], + }], + }], + }; + const card = buildMapSubjectCardModel(fact, { + profile, + aspects: { + identity: { + bindingId: "trike-unit-identity", + dataProductId: "fleet.units.identity.current.v1", + dataClass: "restricted", + fact: { + sourceId: fact.sourceId, + semanticType: "map.moving_object", + attributes: { + device_imei: "867236078012345", + available_user_logins: ["vasya", "dispatcher"], + custom_fields: ["Serial number=5015", "Battery number=8032"], + }, + }, + }, + }, + }); + assert.deepEqual( + card.tabs[0].sections[0].rows.map((row) => row.value), + [ + "867236078012345", + "vasya • dispatcher", + "Serial number: 5015 • Battery number: 8032", + ], + ); +}); + 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"), diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 1f77c9b..2b1a905 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -104,7 +104,7 @@ for (const policy of consumerPolicies) { requireValue(contract?.fieldProjection?.every((value) => typeof value === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(value)), `invalid consumer projection field: ${policy.id ?? "unknown"}`); requireValue(contract?.fieldTypes && typeof contract.fieldTypes === "object" && !Array.isArray(contract.fieldTypes), `invalid consumer field types: ${policy.id ?? "unknown"}`); requireValue(JSON.stringify(Object.keys(contract?.fieldTypes ?? {})) === JSON.stringify(contract?.fieldProjection ?? []), `consumer field type order/scope mismatch: ${policy.id ?? "unknown"}`); - requireValue(Object.values(contract?.fieldTypes ?? {}).every((value) => ["string", "number", "boolean"].includes(value)), `invalid consumer field type: ${policy.id ?? "unknown"}`); + requireValue(Object.values(contract?.fieldTypes ?? {}).every((value) => ["string", "number", "boolean", "string_array"].includes(value)), `invalid consumer field type: ${policy.id ?? "unknown"}`); const supportedGeometryTypes = ["Point", "LineString", "Polygon", "MultiPolygon"]; requireValue( Array.isArray(contract?.geometryTypes) diff --git a/server/foundry-data-product-consumer.mjs b/server/foundry-data-product-consumer.mjs index ac62a3b..73976ea 100644 --- a/server/foundry-data-product-consumer.mjs +++ b/server/foundry-data-product-consumer.mjs @@ -175,7 +175,7 @@ function validateConsumerContract(value) { || fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item)) || !fieldTypes || JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection) - || Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type)) + || Object.values(fieldTypes).some((type) => !["string", "number", "boolean", "string_array"].includes(type)) || geometryTypes.length > supportedGeometryTypes.size || new Set(geometryTypes).size !== geometryTypes.length || geometryTypes.some((type) => !supportedGeometryTypes.has(type)) @@ -230,6 +230,17 @@ function assertFactContract(fact, policy) { if (!contract.semanticTypes.includes(fact.semanticType)) { throw consumerError("data_product_consumer_fact_contract_invalid", 502); } + const attributes = fact?.attributes && typeof fact.attributes === "object" && !Array.isArray(fact.attributes) + ? fact.attributes + : {}; + for (const [field, type] of Object.entries(contract.fieldTypes)) { + if (attributes[field] === undefined) continue; + const value = attributes[field]; + const valid = type === "string_array" + ? Array.isArray(value) && value.every((item) => typeof item === "string") + : typeof value === type && (type !== "number" || Number.isFinite(value)); + if (!valid) throw consumerError("data_product_consumer_fact_contract_invalid", 502); + } const hasGeometry = fact.geometry !== undefined && fact.geometry !== null; if (contract.geometryTypes.length === 0) { if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502); diff --git a/server/foundry-data-product-consumer.test.mjs b/server/foundry-data-product-consumer.test.mjs index 6c3786f..847e79d 100644 --- a/server/foundry-data-product-consumer.test.mjs +++ b/server/foundry-data-product-consumer.test.mjs @@ -615,6 +615,117 @@ test("v4 consumer rejects bindings that omit signal_state and snapshots outside } }); +test("consumer contracts preserve bounded string arrays for classified identity aspects", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-identity-")); + const fieldProjection = ["device_imei", "available_user_logins", "custom_fields"]; + const product = { + id: "fleet.units.identity.current.v1", + version: "1.0.0", + ontologyRevision: "ontology.map.moving_object.v3", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...fieldProjection], + fieldContracts: { + device_imei: { type: "string", required: false }, + available_user_logins: { type: "string_array", required: false }, + custom_fields: { type: "string_array", required: false }, + }, + active: true, + }; + const policy = { + id: "map-moving-object-unit-identity-current-v1", + version: "1.0.0", + dataProductId: product.id, + productVersion: product.version, + dataClass: "restricted", + freshness: "none", + staleAfterMs: null, + terminalStatuses: [], + consumerContract: { + ontologyRevision: product.ontologyRevision, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fieldProjection, + fieldTypes: { + device_imei: "string", + available_user_logins: "string_array", + custom_fields: "string_array", + }, + geometryTypes: [], + subjectIdentity: "semantic-type+source-id", + snapshotMode: "atomic-replace", + patchMode: "atomic", + }, + removeMode: "canonical-tombstone-or-snapshot-rebase", + }; + const target = { + application: { id: "77777777-7777-4777-8777-777777777777" }, + page: { id: "map" }, + binding: { + id: "trike-unit-identity", + dataProductId: product.id, + slotId: "identity", + delivery: "snapshot+patch", + dataClass: "restricted", + semanticTypes: ["map.moving_object"], + fieldProjection, + }, + }; + let invalid = false; + const manager = createFoundryDataProductConsumerManager({ + stateDir, + dataPlaneUrl: "http://edp.test", + resolveTarget: async () => structuredClone(target), + readReaderToken: async () => "ndc_edprb_identity-reader-capability", + inspectReaderGrant: async () => ({ product, readerGrantAction: "reuse", readerGrantGeneration: 1 }), + resolvePolicy: () => policy, + sanitizeSnapshot: (value) => value, + sanitizePatch: (value) => value, + fetchImpl: async () => Response.json({ + schemaVersion: "nodedc.data-product.snapshot/v1", + dataProduct: { id: product.id, version: product.version }, + generatedAt: "2026-07-24T08:00:01.000Z", + cursor: "1", + facts: [{ + sourceId: "gelios-unit-5015", + semanticType: "map.moving_object", + observedAt: "2026-07-24T08:00:00.000Z", + receivedAt: "2026-07-24T08:00:00.500Z", + attributes: { + device_imei: "867236078012345", + available_user_logins: invalid ? "not-an-array" : ["vasya", "dispatcher"], + custom_fields: ["Serial number=5015"], + }, + }], + }), + }); + try { + const input = { + applicationId: target.application.id, + pageId: target.page.id, + bindingId: target.binding.id, + }; + const plan = await manager.plan(input); + await manager.apply({ ...input, planId: plan.planId }); + assert.deepEqual( + (await manager.snapshot(target)).facts[0].attributes.available_user_logins, + ["vasya", "dispatcher"], + ); + assert.equal(plan.configuration.policy.dataClass, "restricted"); + assert.equal(plan.configuration.target.dataClass, "restricted"); + + invalid = true; + const invalidPlan = await manager.plan(input); + await assert.rejects( + manager.apply({ ...input, planId: invalidPlan.planId }), + /data_product_consumer_fact_contract_invalid/, + ); + } finally { + await manager.shutdown(); + await rm(stateDir, { recursive: true, force: true }); + } +}); + const zoneV2Projection = [ "display_name", "geometry_kind", diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index f5ea078..46e5f87 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -320,7 +320,7 @@ const mapSubjectDetailProfileFieldInputSchema = { label: { type: "string", minLength: 1, maxLength: 100 }, format: { type: "string", - enum: ["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"], + enum: ["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "string_list", "telemetry_readings"], }, unit: { type: "string", minLength: 1, maxLength: 24 }, allowedReadingIds: { diff --git a/server/map-subject-detail-profile.mjs b/server/map-subject-detail-profile.mjs index bae1c21..2e118b5 100644 --- a/server/map-subject-detail-profile.mjs +++ b/server/map-subject-detail-profile.mjs @@ -12,7 +12,7 @@ const FIELD_KEYS = new Set([ const SOURCES = new Set(["fact", "attribute", "geometry", "context"]); const FORMATS = new Set([ "text", "number", "timestamp", "boolean", "coordinate", - "signal_state", "movement_state", "telemetry_readings", + "signal_state", "movement_state", "string_list", "telemetry_readings", ]); const DATA_CLASSES = new Set(["operational", "restricted"]); const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]); @@ -98,6 +98,9 @@ function normalizeField(value) { if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) { fail("map_subject_detail_profile_readings_source_invalid"); } + if (format === "string_list" && source !== "attribute") { + fail("map_subject_detail_profile_string_list_source_invalid"); + } if (format !== "telemetry_readings" && allowedReadingIds.length) { fail("map_subject_detail_profile_reading_ids_not_allowed"); } diff --git a/server/map-subject-detail-profile.test.mjs b/server/map-subject-detail-profile.test.mjs index aa32d2f..ceae21e 100644 --- a/server/map-subject-detail-profile.test.mjs +++ b/server/map-subject-detail-profile.test.mjs @@ -47,6 +47,24 @@ test("restricted identifiers require an explicit restricted presentation class", ); }); +test("string list fields are provider-neutral attribute presentations", () => { + const profile = structuredClone(registry.profiles[0]); + const field = profile.tabs[0].sections[0].fields[0]; + field.field = "custom_fields"; + field.label = "Пользовательские поля"; + field.format = "string_list"; + assert.equal( + normalizeMapSubjectDetailProfile(profile).tabs[0].sections[0].fields[0].format, + "string_list", + ); + field.source = "fact"; + field.field = "sourceId"; + assert.throws( + () => normalizeMapSubjectDetailProfile(profile), + /map_subject_detail_profile_string_list_source_invalid/, + ); +}); + test("profiles reject unknown schema fields and duplicate presentation ids", () => { const unknown = structuredClone(registry.profiles[0]); unknown.provider = "gelios";