feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -10,6 +10,9 @@ let xgridsK1Manifest;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
let lifecycle;
|
||||
let projectName;
|
||||
let automaticSourceStart;
|
||||
let presentation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -29,6 +32,15 @@ before(async () => {
|
||||
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",
|
||||
);
|
||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
@@ -279,6 +291,100 @@ test("prepared acquisition resumes without another prepare and remains recoverab
|
||||
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(" Mission 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",
|
||||
@@ -343,6 +449,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||
idempotency_key: "network-provision:test",
|
||||
});
|
||||
await xgridsK1Api.prepareAcquisition({
|
||||
project_name: "Mission 01",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
} finally {
|
||||
@@ -355,4 +462,5 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -39,7 +39,13 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
|
||||
function syntheticPlugin({
|
||||
pluginId,
|
||||
modelId,
|
||||
componentKey,
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}) {
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
@@ -69,6 +75,7 @@ function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
|
||||
},
|
||||
RuntimeProvider: ({ children }) => children,
|
||||
connectionViews: { [componentKey]: connectionView },
|
||||
...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("registry exposes an optional model-scoped spatial controls contribution", () => {
|
||||
const connectionView = () => null;
|
||||
const spatialControlsView = () => null;
|
||||
const registry = createDevicePluginRegistry([
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.spatial",
|
||||
modelId: "synthetic.spatial.sensor",
|
||||
componentKey: "spatial.connection",
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}),
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.connection-only",
|
||||
modelId: "synthetic.connection-only.sensor",
|
||||
componentKey: "connection-only.connection",
|
||||
connectionView,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
|
||||
spatialControlsView,
|
||||
);
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
|
||||
if (existsSync(legacyPluginRoot)) {
|
||||
assert.deepEqual(readdirSync(legacyPluginRoot), []);
|
||||
@@ -108,10 +144,22 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
"styles.css",
|
||||
"components/K1ProvisioningPipeline.tsx",
|
||||
"components/K1AcquisitionPipeline.tsx",
|
||||
"components/K1SpatialControls.tsx",
|
||||
"components/K1Diagnostics.tsx",
|
||||
"projectName.ts",
|
||||
]) {
|
||||
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
|
||||
}
|
||||
|
||||
const spatialControls = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
|
||||
assert.match(spatialControls, /cleanup_pending/);
|
||||
assert.match(spatialControls, /spatialActionFailure/);
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.match(spatialControls, /Повторить остановку/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
|
||||
@@ -603,6 +603,25 @@ test("replay coordinator makes rapid saved-session selection latest-request-wins
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
assert.equal(third.finish(), true);
|
||||
assert.equal(third.finish(), false);
|
||||
});
|
||||
|
||||
test("an acquisition guard cancellation keeps settlement ownership", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const archiveSwitch = coordinator.begin();
|
||||
let replacementBlanked = false;
|
||||
let replacementSettled = false;
|
||||
|
||||
// Models the point after onReplayBegin has released the previous viewer.
|
||||
replacementBlanked = true;
|
||||
coordinator.cancel();
|
||||
|
||||
assert.equal(archiveSwitch.signal.aborted, true);
|
||||
assert.equal(archiveSwitch.isCurrent(), false);
|
||||
if (archiveSwitch.finish()) replacementSettled = true;
|
||||
assert.equal(replacementBlanked, true);
|
||||
assert.equal(replacementSettled, true);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 guard;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function runtimeState(acquisitionState, cleanupPending = false) {
|
||||
return {
|
||||
phase: "idle",
|
||||
sourceMode: "idle",
|
||||
acquisition: acquisitionState === null
|
||||
? null
|
||||
: {
|
||||
acquisitionId: "acq-1",
|
||||
deviceId: "device-1",
|
||||
deviceSessionId: "device-session-1",
|
||||
compatibilityProfileId: "profile-1",
|
||||
controlMode: "operator-manual",
|
||||
state: acquisitionState,
|
||||
stateRevision: 1,
|
||||
operatorInstructions: [],
|
||||
cleanupPending,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
|
||||
for (const state of [
|
||||
"preparing",
|
||||
"prepared",
|
||||
"awaiting_external_start",
|
||||
"starting",
|
||||
"acquiring",
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal or absent acquisitions allow an explicit source selection", () => {
|
||||
for (const state of ["completed", "failed", "aborted", "interrupted"]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
|
||||
}
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
|
||||
});
|
||||
|
||||
test("an unknown acquisition state fails closed", () => {
|
||||
assert.equal(
|
||||
guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
|
||||
true,
|
||||
);
|
||||
assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
|
||||
});
|
||||
|
||||
test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
|
||||
});
|
||||
|
||||
test("saved-session and manual source controls share the acquisition guard", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const sessionSelectSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const observationHookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
|
||||
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
|
||||
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
|
||||
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
|
||||
assert.match(observationHookSource, /!replayEnabledRef\.current/);
|
||||
});
|
||||
Reference in New Issue
Block a user