feat(map): render classified unit identity aspects
This commit is contained in:
parent
49f0c449c1
commit
c859ae4db0
|
|
@ -137,7 +137,7 @@ export type MapSubjectDetailProfile = {
|
||||||
source: "fact" | "attribute" | "geometry" | "context";
|
source: "fact" | "attribute" | "geometry" | "context";
|
||||||
field: string;
|
field: string;
|
||||||
label: 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;
|
unit?: string;
|
||||||
allowedReadingIds?: string[];
|
allowedReadingIds?: string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ export type MapSubjectDetailField = {
|
||||||
source: "fact" | "attribute" | "geometry" | "context";
|
source: "fact" | "attribute" | "geometry" | "context";
|
||||||
field: string;
|
field: string;
|
||||||
label: 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;
|
unit?: string;
|
||||||
allowedReadingIds?: string[];
|
allowedReadingIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
@ -57,7 +58,12 @@ export function buildMapSubjectCardModel(
|
||||||
bindingId?: string;
|
bindingId?: string;
|
||||||
dataProductId?: string;
|
dataProductId?: string;
|
||||||
profile?: MapSubjectDetailProfile;
|
profile?: MapSubjectDetailProfile;
|
||||||
aspects?: Record<string, { fact: MapRuntimeFact; bindingId?: string; dataProductId?: string }>;
|
aspects?: Record<string, {
|
||||||
|
fact: MapRuntimeFact;
|
||||||
|
bindingId?: string;
|
||||||
|
dataProductId?: string;
|
||||||
|
dataClass?: "operational" | "restricted";
|
||||||
|
}>;
|
||||||
},
|
},
|
||||||
): MapSubjectCardModel | null;
|
): MapSubjectCardModel | null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ function buildSection(section, aspects) {
|
||||||
readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds));
|
readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (field.format === "string_list" && (!Array.isArray(value) || value.length === 0)) continue;
|
||||||
if (value === undefined || value === null || value === "") continue;
|
if (value === undefined || value === null || value === "") continue;
|
||||||
if (typeof value === "string" && DIRECT_IDENTIFIER_VALUE.test(value.trim()) && !restricted) continue;
|
if (typeof value === "string" && DIRECT_IDENTIFIER_VALUE.test(value.trim()) && !restricted) continue;
|
||||||
rows.push({
|
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 === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
|
||||||
if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
||||||
if (format === "boolean") return Boolean(value) ? "Да" : "Нет";
|
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)) {
|
if (format === "coordinate" && typeof value === "number" && Number.isFinite(value)) {
|
||||||
return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value);
|
return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,86 @@
|
||||||
},
|
},
|
||||||
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
"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",
|
"id": "map-zone-current-v1",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,81 @@ test("restricted joined aspects render declared identifiers and remain fail-clos
|
||||||
assert.equal(rejected.tabs[0].empty, true);
|
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 () => {
|
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
|
||||||
const [renderer, preview] = await Promise.all([
|
const [renderer, preview] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
|
|
|
||||||
|
|
@ -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?.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(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(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"];
|
const supportedGeometryTypes = ["Point", "LineString", "Polygon", "MultiPolygon"];
|
||||||
requireValue(
|
requireValue(
|
||||||
Array.isArray(contract?.geometryTypes)
|
Array.isArray(contract?.geometryTypes)
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ function validateConsumerContract(value) {
|
||||||
|| fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))
|
|| fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))
|
||||||
|| !fieldTypes
|
|| !fieldTypes
|
||||||
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
|| 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
|
|| geometryTypes.length > supportedGeometryTypes.size
|
||||||
|| new Set(geometryTypes).size !== geometryTypes.length
|
|| new Set(geometryTypes).size !== geometryTypes.length
|
||||||
|| geometryTypes.some((type) => !supportedGeometryTypes.has(type))
|
|| geometryTypes.some((type) => !supportedGeometryTypes.has(type))
|
||||||
|
|
@ -230,6 +230,17 @@ function assertFactContract(fact, policy) {
|
||||||
if (!contract.semanticTypes.includes(fact.semanticType)) {
|
if (!contract.semanticTypes.includes(fact.semanticType)) {
|
||||||
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
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;
|
const hasGeometry = fact.geometry !== undefined && fact.geometry !== null;
|
||||||
if (contract.geometryTypes.length === 0) {
|
if (contract.geometryTypes.length === 0) {
|
||||||
if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
const zoneV2Projection = [
|
||||||
"display_name",
|
"display_name",
|
||||||
"geometry_kind",
|
"geometry_kind",
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ const mapSubjectDetailProfileFieldInputSchema = {
|
||||||
label: { type: "string", minLength: 1, maxLength: 100 },
|
label: { type: "string", minLength: 1, maxLength: 100 },
|
||||||
format: {
|
format: {
|
||||||
type: "string",
|
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 },
|
unit: { type: "string", minLength: 1, maxLength: 24 },
|
||||||
allowedReadingIds: {
|
allowedReadingIds: {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const FIELD_KEYS = new Set([
|
||||||
const SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
|
const SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
|
||||||
const FORMATS = new Set([
|
const FORMATS = new Set([
|
||||||
"text", "number", "timestamp", "boolean", "coordinate",
|
"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 DATA_CLASSES = new Set(["operational", "restricted"]);
|
||||||
const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]);
|
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")) {
|
if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) {
|
||||||
fail("map_subject_detail_profile_readings_source_invalid");
|
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) {
|
if (format !== "telemetry_readings" && allowedReadingIds.length) {
|
||||||
fail("map_subject_detail_profile_reading_ids_not_allowed");
|
fail("map_subject_detail_profile_reading_ids_not_allowed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("profiles reject unknown schema fields and duplicate presentation ids", () => {
|
||||||
const unknown = structuredClone(registry.profiles[0]);
|
const unknown = structuredClone(registry.profiles[0]);
|
||||||
unknown.provider = "gelios";
|
unknown.provider = "gelios";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue