feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user