feat(map): render classified unit identity aspects
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user