feat(foundry): support typed non-geometric subject aspects
This commit is contained in:
@@ -667,6 +667,7 @@ const zoneV2Product = {
|
||||
schedule_timezone: { type: "string", required: false },
|
||||
applies_to_couriers: { type: "boolean", required: false },
|
||||
applies_to_kicksharing: { type: "boolean", required: false },
|
||||
geometry: { type: "geometry", required: true },
|
||||
},
|
||||
active: true,
|
||||
};
|
||||
@@ -862,3 +863,219 @@ test("zone v2 consumer fails closed on projection, product, envelope and geometr
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unitProfileV1Projection = [
|
||||
"corrected_engine_hours_factor",
|
||||
"corrected_mileage_factor",
|
||||
"display_name",
|
||||
"filter_by_satellite_count_enabled",
|
||||
"filter_by_satellite_count_value",
|
||||
"filter_emissions",
|
||||
"hardware_manufacturer_name",
|
||||
"hardware_port",
|
||||
"hardware_type_class",
|
||||
"hardware_type_name",
|
||||
"limit_acceleration",
|
||||
"lost_connection_enabled",
|
||||
"lost_connection_time_value",
|
||||
"maximum_permissible_speed",
|
||||
"maximum_valid_height",
|
||||
"maximum_valid_speed",
|
||||
"mileage_by_ignition",
|
||||
"minimum_movement_speed",
|
||||
"minimum_movement_time",
|
||||
"minimum_parking_time",
|
||||
"minimum_stop_time",
|
||||
"minimum_trip_distance",
|
||||
"minimum_valid_height",
|
||||
"speed_parameter",
|
||||
"trip_detection_type",
|
||||
"unit_type_class",
|
||||
"unit_type_name",
|
||||
"use_odometer",
|
||||
];
|
||||
|
||||
const unitProfileV1FieldTypes = {
|
||||
corrected_engine_hours_factor: "number",
|
||||
corrected_mileage_factor: "number",
|
||||
display_name: "string",
|
||||
filter_by_satellite_count_enabled: "boolean",
|
||||
filter_by_satellite_count_value: "number",
|
||||
filter_emissions: "number",
|
||||
hardware_manufacturer_name: "string",
|
||||
hardware_port: "number",
|
||||
hardware_type_class: "string",
|
||||
hardware_type_name: "string",
|
||||
limit_acceleration: "number",
|
||||
lost_connection_enabled: "boolean",
|
||||
lost_connection_time_value: "number",
|
||||
maximum_permissible_speed: "number",
|
||||
maximum_valid_height: "number",
|
||||
maximum_valid_speed: "number",
|
||||
mileage_by_ignition: "boolean",
|
||||
minimum_movement_speed: "number",
|
||||
minimum_movement_time: "number",
|
||||
minimum_parking_time: "number",
|
||||
minimum_stop_time: "number",
|
||||
minimum_trip_distance: "number",
|
||||
minimum_valid_height: "number",
|
||||
speed_parameter: "string",
|
||||
trip_detection_type: "string",
|
||||
unit_type_class: "string",
|
||||
unit_type_name: "string",
|
||||
use_odometer: "boolean",
|
||||
};
|
||||
|
||||
const unitProfileV1Policy = {
|
||||
id: "map-moving-object-unit-profile-current-v1",
|
||||
version: "1.0.0",
|
||||
dataProductId: "fleet.units.profile.current.v1",
|
||||
productVersion: "1.0.0",
|
||||
freshness: "none",
|
||||
staleAfterMs: null,
|
||||
terminalStatuses: [],
|
||||
consumerContract: {
|
||||
ontologyRevision: "ontology.map.moving_object.v3",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fieldProjection: unitProfileV1Projection,
|
||||
fieldTypes: unitProfileV1FieldTypes,
|
||||
geometryTypes: [],
|
||||
subjectIdentity: "semantic-type+source-id",
|
||||
snapshotMode: "atomic-replace",
|
||||
patchMode: "atomic",
|
||||
},
|
||||
removeMode: "canonical-tombstone-or-snapshot-rebase",
|
||||
};
|
||||
|
||||
const unitProfileV1Product = {
|
||||
id: "fleet.units.profile.current.v1",
|
||||
version: "1.0.0",
|
||||
ontologyRevision: "ontology.map.moving_object.v3",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fields: [...unitProfileV1Projection],
|
||||
fieldContracts: Object.fromEntries(unitProfileV1Projection.map((field) => [
|
||||
field,
|
||||
{ type: unitProfileV1FieldTypes[field], required: field === "display_name" },
|
||||
])),
|
||||
active: true,
|
||||
};
|
||||
|
||||
function unitProfileV1Target(fieldProjection = unitProfileV1Projection) {
|
||||
return {
|
||||
application: { id: "66666666-6666-4666-8666-666666666666" },
|
||||
page: { id: "map" },
|
||||
binding: {
|
||||
id: "trike-unit-profile",
|
||||
dataProductId: unitProfileV1Product.id,
|
||||
slotId: "points",
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fieldProjection: [...fieldProjection],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unitProfileV1Fact({ geometry } = {}) {
|
||||
return {
|
||||
sourceId: "gelios-unit-501",
|
||||
semanticType: "map.moving_object",
|
||||
observedAt: "2026-07-23T06:28:52.000Z",
|
||||
receivedAt: "2026-07-23T06:28:53.000Z",
|
||||
attributes: {
|
||||
display_name: "501 B2",
|
||||
hardware_manufacturer_name: "Gelios",
|
||||
hardware_type_name: "Trike tracker",
|
||||
maximum_permissible_speed: 25,
|
||||
minimum_movement_speed: 3,
|
||||
use_odometer: true,
|
||||
},
|
||||
...(geometry === undefined ? {} : { geometry }),
|
||||
};
|
||||
}
|
||||
|
||||
function unitProfileV1Snapshot(facts = [unitProfileV1Fact()]) {
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.snapshot/v1",
|
||||
dataProduct: { id: unitProfileV1Product.id, version: unitProfileV1Product.version },
|
||||
generatedAt: "2026-07-23T06:28:54.000Z",
|
||||
cursor: "107",
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
test("non-geometric unit profile aspect is strictly typed and joins by stable subject identity", async () => {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-unit-profile-v1-"));
|
||||
const currentTarget = unitProfileV1Target();
|
||||
const manager = createFoundryDataProductConsumerManager({
|
||||
stateDir,
|
||||
dataPlaneUrl: "http://edp.test",
|
||||
resolveTarget: async () => structuredClone(currentTarget),
|
||||
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
|
||||
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||
resolvePolicy: () => unitProfileV1Policy,
|
||||
sanitizeSnapshot: (value) => value,
|
||||
sanitizePatch: (value) => value,
|
||||
fetchImpl: async () => Response.json(unitProfileV1Snapshot()),
|
||||
});
|
||||
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
|
||||
try {
|
||||
const plan = await manager.plan(input);
|
||||
assert.deepEqual(plan.configuration.policy.consumerContract.geometryTypes, []);
|
||||
assert.deepEqual(plan.configuration.policy.consumerContract.fieldProjection, unitProfileV1Projection);
|
||||
const applied = await manager.apply({ ...input, planId: plan.planId });
|
||||
assert.equal(applied.consumer.subjectCount, 1);
|
||||
assert.equal(applied.consumer.subjects[0].subjectId, "gelios-unit-501");
|
||||
assert.equal(applied.consumer.subjects[0].status, "active");
|
||||
const persisted = await manager.snapshot(currentTarget);
|
||||
assert.equal(persisted.facts[0].geometry, undefined);
|
||||
assert.equal(persisted.facts[0].attributes.hardware_type_name, "Trike tracker");
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("non-geometric unit profile aspect fails closed on projection drift and injected geometry", async () => {
|
||||
const variants = [
|
||||
{
|
||||
name: "projection",
|
||||
target: unitProfileV1Target(unitProfileV1Projection.slice(1)),
|
||||
snapshot: unitProfileV1Snapshot(),
|
||||
planError: /data_product_consumer_contract_mismatch/,
|
||||
},
|
||||
{
|
||||
name: "geometry",
|
||||
target: unitProfileV1Target(),
|
||||
snapshot: unitProfileV1Snapshot([unitProfileV1Fact({ geometry: { type: "Point", coordinates: [37.61, 55.75] } })]),
|
||||
applyError: /data_product_consumer_fact_contract_invalid/,
|
||||
},
|
||||
];
|
||||
for (const variant of variants) {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-unit-profile-v1-${variant.name}-`));
|
||||
const manager = createFoundryDataProductConsumerManager({
|
||||
stateDir,
|
||||
dataPlaneUrl: "http://edp.test",
|
||||
resolveTarget: async () => structuredClone(variant.target),
|
||||
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
|
||||
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||
resolvePolicy: () => unitProfileV1Policy,
|
||||
sanitizeSnapshot: (value) => value,
|
||||
sanitizePatch: (value) => value,
|
||||
fetchImpl: async () => Response.json(variant.snapshot),
|
||||
});
|
||||
const input = { applicationId: variant.target.application.id, pageId: variant.target.page.id, bindingId: variant.target.binding.id };
|
||||
try {
|
||||
if (variant.planError) {
|
||||
await assert.rejects(manager.plan(input), variant.planError);
|
||||
} else {
|
||||
const plan = await manager.plan(input);
|
||||
await assert.rejects(manager.apply({ ...input, planId: plan.planId }), variant.applyError);
|
||||
}
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user