diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index cd9fc17..b1070cd 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { after, before, test } from "node:test"; import { createServer } from "vite"; @@ -9,10 +10,14 @@ let createDevicePluginRegistry; let xgridsK1Manifest; let xgridsK1Actions; let xgridsK1Api; +let ApiError; +let localizeRuntimeMessage; let lifecycle; let projectName; let automaticSourceStart; let presentation; +let operatorIntentGeneration; +let configuration; before(async () => { server = await createServer({ @@ -41,9 +46,18 @@ before(async () => { presentation = await server.ssrLoadModule( "@xgrids-k1/frontend/presentation.ts", ); - ({ xgridsK1Api } = await server.ssrLoadModule( + operatorIntentGeneration = await server.ssrLoadModule( + "@xgrids-k1/frontend/operatorIntentGeneration.ts", + ); + configuration = await server.ssrLoadModule( + "@xgrids-k1/frontend/configuration.ts", + ); + ({ xgridsK1Api, ApiError } = await server.ssrLoadModule( "@xgrids-k1/frontend/api.ts", )); + ({ localizeRuntimeMessage } = await server.ssrLoadModule( + "@xgrids-k1/frontend/messages.ts", + )); }); after(async () => { @@ -200,6 +214,151 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", 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"; @@ -404,6 +563,48 @@ test("replay ignores a stale failed live acquisition", () => { ); }); +test("K1 configuration anchors expose future modes without enabling them", () => { + assert.equal(configuration.SUPPORTED_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: true }, + { value: "direct-connect", disabled: true }, + ], + ); + 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("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 = () => { @@ -437,7 +638,7 @@ test("device mutations send explicit nested compatibility attestation", async () const attestation = { firmware_version: "3.0.2", topology: "direct-lan", - operator_confirmed: true, + verification: "live-device-info", }; try { @@ -445,11 +646,14 @@ test("device mutations send explicit nested compatibility attestation", async () 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 { diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx index aa92eab..3036792 100644 --- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx +++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx @@ -1,4 +1,3 @@ -import { useCallback, useState } from "react"; import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react"; import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk"; @@ -19,10 +18,6 @@ import { useXgridsK1Controller } from "./runtimeContext"; export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) { const controller = useXgridsK1Controller(); const { state, error, refresh, clearError } = controller; - const [profileConfirmed, setProfileConfirmed] = useState(false); - const updateProfileConfirmation = useCallback((confirmed: boolean) => { - setProfileConfirmed(confirmed); - }, []); const confirmedLive = isConfirmedLiveState(state); const sourceRuntimeBusy = isSourceRuntimeBusy(state); @@ -51,7 +46,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)