feat(foundry): add composable subject detail profiles
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
|
||||
|
||||
const fact = {
|
||||
sourceId: "867236078012345",
|
||||
sourceId: "gelios-unit-555590",
|
||||
semanticType: "map.moving_object",
|
||||
presentationStatus: "current",
|
||||
observedAt: "2026-07-22T08:42:03.000Z",
|
||||
@@ -31,26 +31,32 @@ const fact = {
|
||||
};
|
||||
|
||||
test("subject card exposes the full provider-neutral telemetry projection", () => {
|
||||
const profile = structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE);
|
||||
profile.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.temperature"];
|
||||
const card = buildMapSubjectCardModel(fact, {
|
||||
bindingId: "trike-current-positions",
|
||||
dataProductId: "fleet.positions.current.v5",
|
||||
profile,
|
||||
});
|
||||
|
||||
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");
|
||||
assert.equal(card.defaultTabId, "overview");
|
||||
assert.deepEqual(card.tabs.map((tab) => tab.id), ["overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics"]);
|
||||
const values = new Map(card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))));
|
||||
assert.equal(values.get("diagnostics.source-id"), fact.sourceId);
|
||||
assert.equal(values.get("diagnostics.product"), "fleet.positions.current.v5");
|
||||
assert.equal(values.get("position.latitude"), "55,75580");
|
||||
assert.equal(values.get("position.longitude"), "37,61760");
|
||||
assert.equal(values.get("overview.signal"), "На связи");
|
||||
assert.equal(values.get("overview.movement"), "В движении");
|
||||
assert.equal(values.get("overview.speed"), "17,4 км/ч");
|
||||
assert.equal(values.get("counters.mileage"), "4 832,25 км");
|
||||
assert.equal(values.get("counters.engine-hours"), "912,5 ч");
|
||||
const readings = card.tabs.find((tab) => tab.id === "telemetry").sections[0].readings;
|
||||
assert.equal(readings[0]?.value, "21,5 °C");
|
||||
assert.equal(card.tabs.find((tab) => tab.id === "equipment").empty, true);
|
||||
});
|
||||
|
||||
test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => {
|
||||
@@ -60,7 +66,8 @@ test("sensor readings remain bounded and reject duplicate, complex and restricte
|
||||
{ id: "sensor.imei", label: "IMEI", value: "forbidden" },
|
||||
{ id: "sensor.payload", label: "Payload", value: { nested: true } },
|
||||
{ id: "Sensor Bad", label: "Bad id", value: 1 },
|
||||
]);
|
||||
{ id: "sensor.account", label: "Аккаунт", value: "+79991234567" },
|
||||
], ["sensor.voltage", "sensor.imei", "sensor.payload", "sensor.account"]);
|
||||
|
||||
assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]);
|
||||
});
|
||||
@@ -76,13 +83,55 @@ test("card model never renders provider secrets or direct identifiers from attri
|
||||
raw_params: "forbidden",
|
||||
},
|
||||
});
|
||||
const keys = card.sections.flatMap((section) => section.rows.map((row) => row.key));
|
||||
const keys = card.tabs.flatMap((tab) => tab.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("unknown fields are fail-closed instead of becoming an automatic Other section", () => {
|
||||
const card = buildMapSubjectCardModel({
|
||||
...fact,
|
||||
attributes: { ...fact.attributes, unclassified_payload: { arbitrary: true }, surprise_value: "must not render" },
|
||||
});
|
||||
const keys = card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => row.key)));
|
||||
assert.ok(!keys.includes("unclassified_payload"));
|
||||
assert.ok(!keys.includes("surprise_value"));
|
||||
assert.equal(card.tabs.some((tab) => tab.id === "other"), false);
|
||||
});
|
||||
|
||||
test("subject details compose declared aspect bindings by stable source id", () => {
|
||||
const profile = {
|
||||
id: "map.subject-detail.composed",
|
||||
version: "1.0.0",
|
||||
title: "Composed",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
defaultTabId: "equipment",
|
||||
tabs: [{
|
||||
id: "equipment",
|
||||
label: "Оборудование",
|
||||
emptyMessage: "Нет данных",
|
||||
sections: [{
|
||||
id: "identity",
|
||||
label: "Профиль",
|
||||
fields: [{ id: "equipment.kind", aspectId: "technical", source: "attribute", field: "equipment_kind", label: "Тип", format: "text" }],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const card = buildMapSubjectCardModel(fact, {
|
||||
profile,
|
||||
aspects: {
|
||||
technical: {
|
||||
bindingId: "trike-technical-profile",
|
||||
dataProductId: "fleet.technical-profile.current.v1",
|
||||
fact: { sourceId: fact.sourceId, semanticType: "asset.technical_profile", attributes: { equipment_kind: "cargo_trike" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike");
|
||||
});
|
||||
|
||||
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"),
|
||||
@@ -95,5 +144,6 @@ test("Cesium pin and label share one entity selection that opens the subject car
|
||||
assert.match(preview, /const handleSelect = useCallback/);
|
||||
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
||||
assert.match(preview, /Телеметрия:/);
|
||||
assert.match(preview, /Карточка объекта:/);
|
||||
assert.match(preview, /SegmentedControl/);
|
||||
});
|
||||
|
||||
@@ -323,12 +323,13 @@ try {
|
||||
return payload.result;
|
||||
};
|
||||
const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } });
|
||||
assert.equal(initialized.serverInfo.version, "0.5.0");
|
||||
assert.equal(initialized.serverInfo.version, "0.6.0");
|
||||
const listedTools = await mcpRequest("tools/list");
|
||||
assert.equal(listedTools.tools.length, 17);
|
||||
assert.equal(listedTools.tools.length, 18);
|
||||
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer"));
|
||||
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_save_map_page_view_state"));
|
||||
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding"));
|
||||
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_upsert_map_subject_detail_profile"));
|
||||
|
||||
const workloadHeaders = {
|
||||
authorization: `Bearer ${bindingGrantToken}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeMapSubjectDetailProfiles } from "../server/map-subject-detail-profile.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const parse = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8"));
|
||||
@@ -16,6 +17,7 @@ const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.sc
|
||||
const mapSceneFixtureSchema = await parse("registry/schemas/map-scene-fixture-v0.1.schema.json");
|
||||
const mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json");
|
||||
const mapPresentationProfiles = await parse("registry/map-presentation-profiles.json");
|
||||
const mapSubjectDetailProfiles = await parse("registry/map-subject-detail-profiles.json");
|
||||
const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json");
|
||||
|
||||
const failures = [];
|
||||
@@ -43,12 +45,20 @@ requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schem
|
||||
requireValue(registry.mapSceneFixtureSchema === "schemas/map-scene-fixture-v0.1.schema.json", "map scene fixture schema must be registered");
|
||||
requireValue(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered");
|
||||
requireValue(registry.mapPresentationProfileRegistry === "map-presentation-profiles.json", "map presentation profile registry must be registered");
|
||||
requireValue(registry.mapSubjectDetailProfileRegistry === "map-subject-detail-profiles.json", "map subject detail profile registry must be registered");
|
||||
requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0");
|
||||
requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0");
|
||||
requireValue(mapSceneFixtureSchema.properties?.schemaVersion?.const === "0.1.0", "map scene fixture schema version must be 0.1.0");
|
||||
requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0");
|
||||
requireValue(mapPresentationProfiles.schemaVersion === "nodedc.map-presentation-profiles/v1", "map presentation profile registry schema is unsupported");
|
||||
requireValue(Array.isArray(mapPresentationProfiles.profiles) && mapPresentationProfiles.profiles.length >= 1, "map presentation profiles are missing");
|
||||
requireValue(mapSubjectDetailProfiles.schemaVersion === "nodedc.map-subject-detail-profiles/v1", "map subject detail profile registry schema is unsupported");
|
||||
try {
|
||||
const normalizedSubjectDetailProfiles = normalizeMapSubjectDetailProfiles(mapSubjectDetailProfiles.profiles);
|
||||
requireValue(normalizedSubjectDetailProfiles.length >= 1, "map subject detail profiles are missing");
|
||||
} catch (error) {
|
||||
requireValue(false, `invalid map subject detail profile registry: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
requireValue(dataProductConsumerPolicies.schemaVersion === "nodedc.foundry.data-product-consumer-policies/v1", "data product consumer policy registry schema is unsupported");
|
||||
requireValue(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user