NODEDC_MISSION_CORE/apps/control-station/test/devicePluginContracts.test.mjs

691 lines
23 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 { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let parseDevicePluginManifest;
let createDevicePluginRegistry;
let xgridsK1Manifest;
let xgridsK1Actions;
let xgridsK1Api;
let ApiError;
let localizeRuntimeMessage;
let lifecycle;
let projectName;
let automaticSourceStart;
let presentation;
let operatorIntentGeneration;
let configuration;
let compatibility;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ parseDevicePluginManifest } = await server.ssrLoadModule(
"/src/core/device-plugins/manifestParser.ts",
));
({ createDevicePluginRegistry } = await server.ssrLoadModule(
"/src/core/device-plugins/registry.ts",
));
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
"@xgrids-k1/frontend/manifest.ts",
));
lifecycle = await server.ssrLoadModule(
"@xgrids-k1/frontend/lifecycle.ts",
);
projectName = await server.ssrLoadModule(
"@xgrids-k1/frontend/projectName.ts",
);
automaticSourceStart = await server.ssrLoadModule(
"@xgrids-k1/frontend/automaticSourceStart.ts",
);
presentation = await server.ssrLoadModule(
"@xgrids-k1/frontend/presentation.ts",
);
operatorIntentGeneration = await server.ssrLoadModule(
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
);
configuration = await server.ssrLoadModule(
"@xgrids-k1/frontend/configuration.ts",
);
compatibility = await server.ssrLoadModule(
"@xgrids-k1/frontend/compatibility.ts",
);
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts",
));
});
after(async () => {
await server?.close();
});
function manifestDocument({
apiVersion = "missioncore.nodedc/v1alpha1",
pluginId = "test.device.plugin",
modelId = "test.device.model",
} = {}) {
const v1alpha2 = apiVersion === "missioncore.nodedc/v1alpha2";
return {
apiVersion,
kind: "DevicePlugin",
metadata: {
id: pluginId,
version: "1.0.0",
displayName: "Test device",
},
spec: {
hostApiRange: v1alpha2 ? "v1alpha2" : "v1alpha1",
runtime: {
backendEntrypoint: "test.plugin:build",
isolation: "transitional-in-process",
},
...(v1alpha2
? {
compatibilityProfiles: [
{
profileId: `${modelId}.fw-1.v1`,
path: "profiles/fw-1/profile.v1.json",
modelId,
},
],
}
: {}),
permissions: ["device.read"],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
models: [
{
id: modelId,
vendor: "Test",
displayName: "Test model",
category: "Sensor",
description: "Fixture model",
verified: true,
capabilities: [{ id: "device.read", label: "Read" }],
ui: { slot: "device.connection", componentKey: "test.connection" },
},
],
},
};
}
function uiPlugin(manifest) {
return {
manifest,
RuntimeProvider: ({ children }) => children,
connectionViews: { "test.connection": () => null },
};
}
test("parser accepts reviewed v1alpha1 and v1alpha2 shapes", () => {
const legacy = parseDevicePluginManifest(manifestDocument());
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
assert.equal(legacy.apiVersion, "missioncore.nodedc/v1alpha1");
assert.equal(legacy.spec.hostApiRange, "v1alpha1");
assert.equal(current.apiVersion, "missioncore.nodedc/v1alpha2");
assert.equal(current.spec.hostApiRange, "v1alpha2");
assert.deepEqual(current.spec.compatibilityProfiles, [
{
profileId: "test.device.model.fw-1.v1",
path: "profiles/fw-1/profile.v1.json",
modelId: "test.device.model",
},
]);
});
test("v1alpha1 keeps the original nonblank identifier compatibility", () => {
const legacyDocument = manifestDocument({
pluginId: "legacy plugin id",
modelId: "legacy model id",
});
legacyDocument.spec.permissions = ["legacy permission"];
legacyDocument.spec.actions[0].secretFields = ["legacy secret field"];
legacyDocument.spec.models[0].capabilities = [
{ id: "legacy capability", label: "Legacy" },
];
const legacy = parseDevicePluginManifest(legacyDocument);
assert.equal(legacy.metadata.id, "legacy plugin id");
assert.equal(legacy.spec.models[0].id, "legacy model id");
assert.deepEqual(legacy.spec.permissions, ["legacy permission"]);
});
test("v1alpha2 applies strict identifiers without redefining v1alpha1", () => {
const current = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "current plugin id",
});
assert.throws(
() => parseDevicePluginManifest(current),
/не является идентификатором/,
);
});
test("v1alpha2 parser and registry accept multiple independently profiled models", () => {
const document = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.family",
modelId: "test.family.model-a",
});
document.spec.models.push({
...document.spec.models[0],
id: "test.family.model-b",
displayName: "Test model B",
});
document.spec.compatibilityProfiles.push({
profileId: "test.family.model-b.fw-1.v1",
path: "profiles/fw-1/model-b.v1.json",
modelId: "test.family.model-b",
});
const manifest = parseDevicePluginManifest(document);
const plugin = {
...uiPlugin(manifest),
connectionViews: { "test.connection": () => null },
};
const registry = createDevicePluginRegistry([plugin]);
assert.deepEqual(registry.models.map(({ model }) => model.id), [
"test.family.model-a",
"test.family.model-b",
]);
});
test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", () => {
assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
{
profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
path: "profiles/fw-3.0.2/local-network.v2.json",
modelId: "xgrids.lixelkity-k1",
},
]);
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
});
test("one operator action can open at most one K1 control session", () => {
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
assert.equal(
lifecycle.controlSessionEntryPlan("idle", true, true),
"duplicate-open",
);
assert.equal(
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
"continue",
);
});
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
});
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
let resolveOldRead;
const oldRead = new Promise((resolve) => {
resolveOldRead = resolve;
});
const writes = [];
const assertOldIntent = () => {
if (!generation.isOperatorIntentCurrent(oldIntent)) {
throw new Error("stale operator intent");
}
};
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
assertOldIntent,
() => oldRead,
).then(() => writes.push("old-checkpoint"));
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
resolveOldRead({ phase: "connection-ready" });
await assert.rejects(oldContinuation, /stale operator intent/);
assert.deepEqual(writes, []);
const assertFreshIntent = () => {
if (!generation.isOperatorIntentCurrent(freshIntent)) {
throw new Error("stale fresh intent");
}
};
await operatorIntentGeneration.awaitWhileIntentCurrent(
assertFreshIntent,
async () => ({ phase: "connection-ready" }),
);
writes.push("fresh-checkpoint");
assert.deepEqual(writes, ["fresh-checkpoint"]);
});
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const first = generation.beginOperatorIntent();
const second = generation.beginOperatorIntent();
assert.ok(first);
assert.ok(second);
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
assert.notEqual(first.intentGeneration, second.intentGeneration);
assert.equal(generation.isOperatorIntentCurrent(first), false);
assert.equal(generation.isOperatorIntentCurrent(second), true);
});
test("canonical K1 launch wires the generation guard through every async stage", async () => {
const hookSource = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
import.meta.url,
),
"utf8",
);
const canonicalStart = hookSource.slice(
hookSource.indexOf("const startCanonicalAcquisition"),
hookSource.indexOf("const prepareAcquisition"),
);
const pollingLoop = hookSource.slice(
hookSource.indexOf("async function waitForControlPhase"),
hookSource.indexOf("function messageFor"),
);
assert.doesNotMatch(hookSource, /mounted\.current/);
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
assert.ok(
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
"each canonical REST/checkpoint boundary must use the intent guard",
);
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
});
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
assert.equal(
localizeRuntimeMessage(
"control MQTT connect call failed: [Errno 61] Connection refused",
),
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
);
const domainError = new ApiError("Диалог остановлен до команды START.");
assert.equal(domainError.transportUnavailable, false);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new TypeError("synthetic network failure");
};
try {
await assert.rejects(
() => xgridsK1Api.getState(),
(error) => error instanceof ApiError && error.transportUnavailable === true,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].status = "verified";
assert.throws(
() => parseDevicePluginManifest(document),
/неизвестные поля status/,
);
});
test("v1alpha2 compatibility profile fails closed on unsafe paths", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].path = "../private/profile.json";
assert.throws(
() => parseDevicePluginManifest(document),
/безопасным относительным JSON-путём/,
);
});
test("v1alpha2 compatibility profile cannot reference an unknown model", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => parseDevicePluginManifest(document),
/ссылается на неизвестную модель missing\.model/,
);
});
test("registry accepts v1alpha1 and v1alpha2 plugins together", () => {
const legacy = parseDevicePluginManifest(
manifestDocument({ pluginId: "test.legacy", modelId: "test.legacy.model" }),
);
const current = parseDevicePluginManifest(
manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.current",
modelId: "test.current.model",
}),
);
const registry = createDevicePluginRegistry([uiPlugin(legacy), uiPlugin(current)]);
assert.equal(registry.plugins.length, 2);
assert.equal(registry.models.length, 2);
assert.equal(registry.resolveModel("test.current.model")?.plugin.manifest.metadata.id, "test.current");
});
test("registry independently rejects an uncovered v1alpha2 model", () => {
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
current.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => createDevicePluginRegistry([uiPlugin(current)]),
/ссылается на неизвестную модель missing\.model/,
);
});
test("live source is confirmed only by an acquiring acquisition", () => {
const waiting = {
source_mode: "live",
phase: "live",
acquisition: { state: "awaiting_external_start" },
};
const acquiring = {
...waiting,
acquisition: { state: "acquiring" },
};
assert.equal(lifecycle.isConfirmedLiveState(waiting), false);
assert.equal(lifecycle.confirmedRuntimeSourceMode(waiting), "idle");
assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных");
assert.equal(lifecycle.isConfirmedLiveState(acquiring), true);
assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live");
});
test("prepared acquisition resumes without another prepare and remains recoverable", () => {
const prepared = {
source_mode: "idle",
acquisition: {
acquisition_id: "acq-1",
state: "prepared",
},
};
assert.equal(lifecycle.liveStartPlan(prepared), "resume-prepared");
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
});
test("project name is canonicalized and rejected outside the bounded safe contract", () => {
assert.deepEqual(projectName.validateProjectName("  01 "), {
value: "Mission 01",
error: null,
});
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
});
test("automatic spatial source replaces the old scene only after a successful start", async () => {
const failedEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
failedEvents.push("start");
return false;
},
() => failedEvents.push("activate"),
() => failedEvents.push("open"),
), false);
assert.deepEqual(failedEvents, ["start"]);
const successfulEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
successfulEvents.push("start");
return true;
},
() => successfulEvents.push("activate"),
() => successfulEvents.push("open"),
), true);
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
});
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
const capability = {
compatibility: {
vendor_writes_enabled: true,
permitted_mode: "active-control",
},
};
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
assert.equal(lifecycle.isVendorWriteCapable({
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "operator-manual" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "plugin-commanded" },
}), true);
});
test("device modeling telemetry maps only finite non-negative values", () => {
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: 12.5,
device_route_distance_meters: 8.25,
device_speed_meters_per_second: 0.75,
}), {
elapsedSeconds: 12.5,
routeDistanceMeters: 8.25,
speedMetersPerSecond: 0.75,
});
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: -1,
device_route_distance_meters: Number.NaN,
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
}), {
elapsedSeconds: null,
routeDistanceMeters: null,
speedMetersPerSecond: null,
});
});
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
assert.equal(presentation.spatialActionFailure(null), null);
assert.equal(presentation.spatialActionFailure(" "), null);
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
title: "Действие K1 не выполнено",
detail: "stop failed",
});
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "live",
acquisition: { state: "failed", cleanup_pending: true },
}), true);
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "idle",
acquisition: { state: "failed", cleanup_pending: false },
}), false);
});
test("replay ignores a stale failed live acquisition", () => {
const replay = {
source_mode: "replay",
phase: "replay",
rerun_grpc_url: "rerun+http://127.0.0.1:9876/proxy",
acquisition: {
acquisition_id: "old-live-acquisition",
state: "failed",
},
};
assert.equal(lifecycle.normalizeRuntimePhase(replay), "replaying");
assert.equal(lifecycle.effectiveAcquisition(replay), null);
assert.equal(
lifecycle.spatialSourceId(replay, replay.rerun_grpc_url),
`replay:${replay.rerun_grpc_url}`,
);
});
test("K1 configuration exposes all reviewed local connection directions", () => {
assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge");
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
assert.deepEqual(
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "bridge", disabled: false },
{ value: "quick-connect", disabled: false },
{ value: "direct-connect", disabled: false },
],
);
assert.deepEqual(
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "handheld", disabled: false },
{ value: "vehicle-mounted", disabled: true },
{ value: "uav", disabled: true },
{ value: "backpack", disabled: true },
],
);
assert.deepEqual(
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "none", disabled: false },
{ value: "rtk", disabled: true },
{ value: "ppk", disabled: true },
],
);
});
test("connection directions select distinct fail-closed topologies", () => {
assert.deepEqual(compatibility.profileSelectionForConnectionMode("bridge"), {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
});
assert.equal(
compatibility.profileSelectionForConnectionMode("quick-connect").topology,
"device-ap",
);
assert.equal(
compatibility.profileSelectionForConnectionMode("direct-connect").topology,
"controller-hotspot",
);
});
test("a provisioned address is not presented as a verified device connection", () => {
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "idle" },
}), "configuring");
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "connection-ready" },
}), "connected");
});
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
let created = 0;
const createUuid = () => {
created += 1;
return "11111111-1111-4111-8111-111111111111";
};
const first = lifecycle.provisioningIntentKey(null, createUuid);
const repeated = lifecycle.provisioningIntentKey(first, createUuid);
const failedOperation = {
status: "failed",
error: { safe_to_retry: false },
};
assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111");
assert.equal(repeated, first);
assert.equal(created, 1);
assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true);
});
test("device mutations send explicit nested compatibility attestation", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
const syntheticCredential = "x".repeat(32);
globalThis.fetch = async (path, init) => {
calls.push({ path, init });
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const attestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
};
try {
await xgridsK1Api.connect({
device_id: "ble-device",
ssid: "lab-network",
password: syntheticCredential,
connection_mode: "bridge",
compatibility_attestation: attestation,
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
mount_type: "handheld",
gnss_mode: "none",
compatibility_attestation: attestation,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(calls.length, 2);
const provisioning = JSON.parse(calls[0].init.body);
const prepare = JSON.parse(calls[1].init.body);
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01");
});