NODEDC_DESIGN_GUIDELINE/scripts/map-subject-card.test.mjs

292 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs";
const fact = {
sourceId: "gelios-unit-555590",
semanticType: "map.moving_object",
presentationStatus: "current",
observedAt: "2026-07-22T08:42:03.000Z",
receivedAt: "2026-07-22T08:42:05.000Z",
geometry: { type: "Point", coordinates: [37.6176, 55.7558] },
attributes: {
display_name: "555590 B2 867236078012345",
signal_state: "active",
movement_state: "moving",
speed_kph: 17.4,
course_degrees: 211,
elevation_meters: 142,
satellite_count: 12,
hdop: 0.7,
horizontal_accuracy_meters: 3.5,
mileage_km: 4832.25,
engine_hours: 912.5,
object_kind: "trike",
position_source: "gelios",
sensor_readings: [
{ id: "sensor.temperature", label: "Температура", value: 21.5, unit: "°C", observedAt: "2026-07-22T08:42:02.000Z" },
],
},
};
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);
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", () => {
const readings = normalizeTelemetryReadings([
{ id: "sensor.voltage", label: "Напряжение", value: 48.2, unit: "V" },
{ id: "sensor.voltage", label: "Повтор", value: 49 },
{ 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 }]);
});
test("card model never renders provider secrets or direct identifiers from attributes", () => {
const card = buildMapSubjectCardModel({
...fact,
attributes: {
...fact.attributes,
api_key: "forbidden",
imei: "forbidden",
phone: "+70000000000",
raw_params: "forbidden",
},
});
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("restricted joined aspects render declared identifiers and remain fail-closed otherwise", () => {
const profile = {
id: "map.subject-detail.restricted",
version: "1.0.0",
title: "Restricted",
semanticTypes: ["map.moving_object"],
defaultTabId: "contacts",
tabs: [{
id: "contacts",
label: "Контакты",
emptyMessage: "Нет данных",
sections: [{
id: "device",
label: "Контакты устройства",
fields: [
{
id: "contacts.phone",
aspectId: "unit_contacts",
source: "attribute",
field: "device_phone_primary",
label: "Телефон 1",
format: "text",
dataClass: "restricted",
},
{
id: "contacts.imei",
aspectId: "unit_contacts",
source: "attribute",
field: "device_imei",
label: "IMEI",
format: "text",
dataClass: "restricted",
},
],
}],
}],
};
const contactAspect = {
bindingId: "trike-unit-contacts",
dataProductId: "fleet.units.contacts.current.v1",
dataClass: "restricted",
fact: {
sourceId: fact.sourceId,
semanticType: "map.moving_object",
attributes: {
device_phone_primary: "+79991234567",
device_imei: "867236078012345",
},
},
};
const card = buildMapSubjectCardModel(fact, {
profile,
aspects: { unit_contacts: contactAspect },
});
assert.deepEqual(
card.tabs[0].sections[0].rows.map((row) => row.value),
["+79991234567", "867236078012345"],
);
const rejected = buildMapSubjectCardModel(fact, {
profile,
aspects: {
unit_contacts: { ...contactAspect, dataClass: "operational" },
},
});
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"),
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
]);
assert.match(renderer, /viewer\?\.scene\.pick/);
assert.match(renderer, /pickedId instanceof Entity/);
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
assert.match(preview, /const handleSelect = useCallback/);
assert.match(preview, /setSubjectCardOpen\(true\)/);
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
assert.match(preview, /Карточка объекта:/);
assert.match(preview, /SegmentedControl/);
});