236 lines
7.9 KiB
JavaScript
236 lines
7.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildFilteredMapTargets,
|
|
buildMapPresentationFilters,
|
|
buildSelectableMapEntities,
|
|
planMapSubjectReveal,
|
|
primaryMapRuntimeBindings,
|
|
} from "../apps/catalog/src/mapWorkspaceModel.mjs";
|
|
import { mapRuntimeEntityId, mapRuntimeFactKey } from "../apps/catalog/src/mapRuntimeIdentity.mjs";
|
|
import { localSectorAtGeodetic, localSectorSummary } from "../apps/catalog/src/mapSectorGrid.mjs";
|
|
|
|
const origin = { latitude: 55.7558, longitude: 37.6173 };
|
|
const gridProfile = {
|
|
mode: "3d",
|
|
stepKm: 1,
|
|
tileSizeKm: 5,
|
|
graticuleStepDegrees: 1,
|
|
};
|
|
const primaryBinding = {
|
|
id: "positions",
|
|
order: 1,
|
|
dataProductId: "fleet.positions.current.v5",
|
|
slotId: "points",
|
|
delivery: "snapshot+patch",
|
|
semanticTypes: ["map.moving_object"],
|
|
fieldProjection: ["display_name", "signal_state", "position_source", "object_kind"],
|
|
presentationProfileId: "moving",
|
|
};
|
|
const joinedBinding = {
|
|
...primaryBinding,
|
|
id: "identity",
|
|
order: 2,
|
|
dataProductId: "fleet.units.identity.current.v1",
|
|
joinToBindingId: "positions",
|
|
};
|
|
const profile = {
|
|
id: "moving",
|
|
title: "Current positions",
|
|
semanticTypes: ["map.moving_object"],
|
|
label: { mode: "attributes", fields: ["display_name"], maxLength: 80 },
|
|
target: { variant: "elevated-spike" },
|
|
facets: [{
|
|
id: "signal",
|
|
field: "signal_state",
|
|
filterable: true,
|
|
counter: true,
|
|
values: [
|
|
{ value: "active", label: "Online", order: 1 },
|
|
{ value: "inactive", label: "Offline", order: 2 },
|
|
],
|
|
}],
|
|
styles: [{ id: "active", color: "#fff", opacity: 1 }],
|
|
classes: [{ id: "active", label: "Online", priority: 1, match: [], styleId: "active", renderable: true }],
|
|
defaultClassId: "active",
|
|
sort: [{ field: "signal_state", order: ["active", "inactive"] }],
|
|
};
|
|
|
|
function pointFact(sourceId, signalState, coordinates, displayName = sourceId) {
|
|
return {
|
|
sourceId,
|
|
semanticType: "map.moving_object",
|
|
observedAt: "2026-08-09T12:00:00.000Z",
|
|
receivedAt: "2026-08-09T12:00:01.000Z",
|
|
attributes: {
|
|
display_name: displayName,
|
|
signal_state: signalState,
|
|
position_source: "gelios",
|
|
object_kind: "tracked_unit",
|
|
},
|
|
geometry: { type: "Point", coordinates },
|
|
presentationStatus: signalState,
|
|
};
|
|
}
|
|
|
|
const inactive = pointFact("unit-offline", "inactive", [37.6173, 55.7558], "Альфа");
|
|
const active = pointFact("unit-online", "active", [37.618, 55.756], "Янтарь");
|
|
const runtimeBindings = [{
|
|
bindingId: "positions",
|
|
dataProductId: primaryBinding.dataProductId,
|
|
slotId: "points",
|
|
presentationProfileId: "moving",
|
|
facts: [inactive, active],
|
|
cursor: "1",
|
|
state: "ready",
|
|
}, {
|
|
bindingId: "identity",
|
|
dataProductId: joinedBinding.dataProductId,
|
|
slotId: "subject-aspect",
|
|
presentationProfileId: "moving",
|
|
facts: [active],
|
|
cursor: "1",
|
|
state: "ready",
|
|
}];
|
|
|
|
test("stable runtime identity uses binding, semantic type and source id only", () => {
|
|
const moved = { ...active, geometry: { type: "Point", coordinates: [40, 60] } };
|
|
assert.equal(mapRuntimeFactKey(active), "map.moving_object\u0000unit-online");
|
|
assert.equal(mapRuntimeEntityId("positions", active), mapRuntimeEntityId("positions", moved));
|
|
assert.equal(mapRuntimeEntityId("positions", active), "nodedc-runtime:positions:map.moving_object:unit-online");
|
|
});
|
|
|
|
test("joined aspects enrich a primary subject and never become map entities", () => {
|
|
assert.deepEqual(
|
|
primaryMapRuntimeBindings(runtimeBindings, [joinedBinding, primaryBinding]).map((binding) => binding.bindingId),
|
|
["positions"],
|
|
);
|
|
assert.deepEqual(
|
|
buildSelectableMapEntities(runtimeBindings, [joinedBinding, primaryBinding], [profile]).map((entity) => entity.fact.sourceId),
|
|
["unit-online", "unit-offline"],
|
|
);
|
|
});
|
|
|
|
test("object overview preserves the same MCP profile sort as facets and renderer", () => {
|
|
const filters = { positions: { visible: true, facets: {} } };
|
|
const selectable = buildSelectableMapEntities(runtimeBindings, [primaryBinding, joinedBinding], [profile]);
|
|
const targets = buildFilteredMapTargets(runtimeBindings, [primaryBinding, joinedBinding], [profile], filters);
|
|
|
|
// Alphabetical title order would be Альфа, Янтарь. Declared state order is
|
|
// active, inactive and must remain authoritative everywhere.
|
|
assert.deepEqual(selectable.map((entity) => entity.title), ["Янтарь", "Альфа"]);
|
|
assert.deepEqual(targets.map((target) => target.title), ["Янтарь", "Альфа"]);
|
|
});
|
|
|
|
test("explicit empty facets remain match-nothing through workspace projection", () => {
|
|
const subjectStates = {
|
|
positions: {
|
|
bindingId: "positions",
|
|
visible: true,
|
|
filters: { signal_state: [] },
|
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
|
},
|
|
};
|
|
const filters = buildMapPresentationFilters(subjectStates, [primaryBinding], [profile]);
|
|
assert.deepEqual(filters.positions.facets, { signal_state: [] });
|
|
assert.deepEqual(buildFilteredMapTargets(runtimeBindings, [primaryBinding], [profile], filters), []);
|
|
});
|
|
|
|
test("search reveal removes only constraints hiding the selected subject", () => {
|
|
const address = localSectorAtGeodetic(origin, {
|
|
lod: 1,
|
|
originLatitude: origin.latitude,
|
|
originLongitude: origin.longitude,
|
|
stepMeters: 1_000,
|
|
});
|
|
const selectedSector = {
|
|
...localSectorSummary(address, {
|
|
lod: 1,
|
|
originLatitude: origin.latitude,
|
|
originLongitude: origin.longitude,
|
|
stepMeters: 1_000,
|
|
tileSizeMeters: 5_000,
|
|
}),
|
|
mode: "3d",
|
|
units: "meters-enu",
|
|
volume: null,
|
|
};
|
|
const entity = buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])
|
|
.find((candidate) => candidate.fact.sourceId === "unit-offline");
|
|
const subjectState = {
|
|
bindingId: "positions",
|
|
visible: false,
|
|
filters: { signal_state: ["active"] },
|
|
window: { open: true, rect: { x: 10, y: 20, width: 280, height: 260 }, maximized: false, zIndex: 42 },
|
|
};
|
|
const reveal = planMapSubjectReveal({
|
|
entity,
|
|
subjectState,
|
|
profile,
|
|
selectedSector,
|
|
gridProfiles: [gridProfile],
|
|
origin,
|
|
hideOutsideSector: true,
|
|
scope: {
|
|
excludedBindingIds: ["positions", "other"],
|
|
excludedProviders: ["gelios", "other"],
|
|
excludedObjectKinds: ["tracked_unit", "other"],
|
|
},
|
|
});
|
|
|
|
assert.equal(reveal.subjectState.visible, true);
|
|
assert.deepEqual(reveal.subjectState.filters, {});
|
|
assert.deepEqual(reveal.subjectState.window, subjectState.window);
|
|
assert.equal(reveal.hideOutsideSector, true);
|
|
assert.deepEqual(reveal.scope, {
|
|
excludedBindingIds: ["other"],
|
|
excludedProviders: ["other"],
|
|
excludedObjectKinds: ["other"],
|
|
});
|
|
});
|
|
|
|
test("search keeps the active sector but relaxes outside clipping for an outside subject", () => {
|
|
const address = localSectorAtGeodetic(origin, {
|
|
lod: 1,
|
|
originLatitude: origin.latitude,
|
|
originLongitude: origin.longitude,
|
|
stepMeters: 1_000,
|
|
});
|
|
const selectedSector = {
|
|
...localSectorSummary(address, {
|
|
lod: 1,
|
|
originLatitude: origin.latitude,
|
|
originLongitude: origin.longitude,
|
|
stepMeters: 1_000,
|
|
tileSizeMeters: 5_000,
|
|
}),
|
|
mode: "3d",
|
|
units: "meters-enu",
|
|
volume: null,
|
|
};
|
|
const outsideEntity = {
|
|
...buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])[0],
|
|
fact: pointFact("far-away", "active", [50, 60]),
|
|
};
|
|
const subjectState = {
|
|
bindingId: "positions",
|
|
visible: true,
|
|
filters: {},
|
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
|
};
|
|
const reveal = planMapSubjectReveal({
|
|
entity: outsideEntity,
|
|
subjectState,
|
|
profile,
|
|
selectedSector,
|
|
gridProfiles: [gridProfile],
|
|
origin,
|
|
hideOutsideSector: true,
|
|
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
|
|
});
|
|
|
|
assert.equal(reveal.hideOutsideSector, false);
|
|
assert.equal(selectedSector.id, reveal ? selectedSector.id : null);
|
|
});
|