Стабилизация переключения Bridge и Quick Connect

Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру.

Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения.

Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 00:15:28 +03:00
parent 1001a31638
commit 0752e5c6bf
12 changed files with 3481 additions and 234 deletions
@@ -17,6 +17,7 @@ let physicalRecoveryConnectionDetail;
let shouldRenderK1OperationalPanels;
let K1ProvisioningPipeline;
let RuntimeActionFenceTestContext;
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
let emptySearchPresentation;
let emptyProvisioningAttemptPresentation;
let emptyReadOnlyReconnectPresentation;
@@ -127,6 +128,7 @@ before(async () => {
({
K1ProvisioningPipeline,
RuntimeActionFenceTestContext,
LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
emptySearchPresentation,
emptyProvisioningAttemptPresentation,
emptyReadOnlyReconnectPresentation,
@@ -3245,6 +3247,7 @@ test("replay and local-only stop branches use bounded process copy", () => {
const stopState = runtimeState();
stopState.compatibility.vendor_writes_enabled = false;
stopState.acquisition.control_mode = "operator-manual";
stopState.connection_policy = {
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
facts: { retained_context_is_presence: false },
@@ -3272,7 +3275,68 @@ test("replay and local-only stop branches use bounded process copy", () => {
assertCanonicalConnectionCopy(stopMarkup);
});
test("physical STOP is fail-closed and both acquisition surfaces fall back to local cleanup", () => {
test("software-commanded calibration exposes no teardown until canonical STOP is safe", () => {
const state = runtimeState();
state.snapshot_runtime_id = "runtime-calibration-001";
state.snapshot_revision = 31;
state.phase = "starting_live";
state.source_mode = "live";
state.acquisition.state = "awaiting_external_start";
state.acquisition.state_revision = 3;
state.application_control_session = {
session_generation: 5,
state_revision: 7,
state: "device-initializing",
can_stop: false,
control_socket_open: true,
};
state.connection_policy = {
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
facts: { retained_context_is_presence: false },
allowed_actions: ["stop-local-receiver"],
actions: {
"stop-acquisition": {
allowed: false,
reason_codes: ["supervisor-action-not-allowed"],
target_source: "connection-supervisor",
required_transport_ref: null,
required_connection_mode: null,
requires_live_gatt_validation: false,
automatic_retry: false,
},
"stop-local-receiver": {
allowed: true,
reason_codes: [],
target_source: "local-runtime",
required_transport_ref: null,
required_connection_mode: null,
requires_live_gatt_validation: false,
automatic_retry: false,
},
},
};
const pipelineMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
controller: acquisitionController(state),
desiredConnectionMode: "bridge",
openSpatialScene() {},
activateAutomaticSpatialSource() {},
}));
const spatialMarkup = renderToStaticMarkup(createElement(K1SpatialControlsView, {
controller: acquisitionController(state),
}));
assert.match(pipelineMarkup, /Кнопка остановки появится только после подтверждённого SCANNING/);
assert.match(spatialMarkup, /K1 калибруется и готовит облако точек/);
for (const markup of [pipelineMarkup, spatialMarkup]) {
assert.equal(buttonMarkupWithText(markup, "Завершить локальный приём").length, 0);
assert.equal(buttonMarkupWithText(markup, "Аварийно завершить локальный приём").length, 0);
assert.equal(buttonMarkupWithText(markup, "Остановить устройство и запись").length, 0);
assert.equal(buttonMarkupWithText(markup, "Отменить запуск до START").length, 0);
}
});
test("physical STOP is fail-closed across both acquisition surfaces", () => {
const state = runtimeState();
state.snapshot_runtime_id = "runtime-stop-001";
state.snapshot_revision = 40;
@@ -3442,13 +3506,15 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
},
));
assert.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/);
assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/);
const localButtons = buttonMarkupWithText(
dismissedFailureMarkup,
"Завершить локальный приём",
);
assert.equal(localButtons.length, 1);
assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/);
assert.equal(localButtons.length, 0);
assert.match(
dismissedFailureMarkup,
/Повторная команда и локальное завершение заблокированы/,
);
assertCanonicalConnectionCopy(dismissedFailureMarkup);
const classifiedStopState = structuredClone(state);
@@ -3485,7 +3551,11 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
activateAutomaticSpatialSource() {},
},
));
assert.match(localCleanupPendingMarkup, /Завершение локального приёма…/);
assert.doesNotMatch(localCleanupPendingMarkup, /Завершение локального приёма…/);
assert.match(
localCleanupPendingMarkup,
/Повторная команда и локальное завершение заблокированы/,
);
assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/);
const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
@@ -5802,6 +5872,78 @@ test("physical recovery new-device action performs only an explicit scenario res
assert.equal(reopenCalls, 0);
});
test("topology switch stays silent for two seconds and then uses only the inline loader", async () => {
const state = terminalConnectionRecoveryState();
state.connection_attempt = null;
let resolveReset;
const resetSettlement = new Promise((resolve) => {
resolveReset = resolve;
});
const resetRequests = [];
const controller = {
...provisioningController(state),
selectConnectionMode: (request) => {
resetRequests.push(request);
return resetSettlement;
},
};
const props = {
controller,
desiredMode: "bridge",
onDesiredModeChange: () => undefined,
};
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const timers = [];
globalThis.setTimeout = (callback, delay, ...args) => {
const timer = { callback, delay, args, cleared: false };
timers.push(timer);
return timer;
};
globalThis.clearTimeout = (timer) => {
timer.cleared = true;
};
const harness = createStatefulProvisioningHarness(props);
try {
let tree = harness.render();
harness.flushEffects();
const modeSelect = elementByProp(tree, "label", "Способ подключения");
assert.ok(modeSelect);
modeSelect.props.onChange("quick-connect");
assert.equal(resetRequests.length, 1);
tree = harness.render();
harness.flushEffects();
let markup = renderToStaticMarkup(tree);
assert.doesNotMatch(markup, /Переключаем способ подключения/);
assert.doesNotMatch(markup, /Завершение прежней локальной границы/);
const progressTimer = timers.find(
(timer) => timer.delay === LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
);
assert.ok(progressTimer);
assert.equal(progressTimer.cleared, false);
progressTimer.callback(...progressTimer.args);
tree = harness.render();
markup = renderToStaticMarkup(tree);
assert.match(markup, /Переключаем способ подключения/);
assert.match(markup, /connection-action-progress/);
assert.doesNotMatch(markup, /dialog|modal|window/i);
resolveReset(true);
await Promise.resolve();
await Promise.resolve();
tree = harness.render();
harness.flushEffects();
markup = renderToStaticMarkup(tree);
assert.doesNotMatch(markup, /Переключаем способ подключения/);
} finally {
harness.dispose();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => {
let currentRuntimeId = "runtime-B";
let mutationRequests = 0;
@@ -6360,7 +6502,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
...provisioningController(coldQuickState),
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
},
desiredMode: "bridge",
desiredMode: "quick-connect",
});
const coldQuickReconnect = buttonMarkupWithText(
coldQuickMarkup,
@@ -6374,6 +6516,32 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
);
assert.match(coldQuickMarkup, /Quick Connect/);
const quickTargetInsideBridgeMarkup = renderProvisioning({
controller: {
...provisioningController(coldQuickState),
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
},
desiredMode: "bridge",
});
assert.equal(
buttonMarkupWithText(
quickTargetInsideBridgeMarkup,
"Переподключиться",
).length,
0,
);
assert.equal(
buttonMarkupWithText(
quickTargetInsideBridgeMarkup,
"Найти по Bluetooth",
).length,
1,
);
assert.doesNotMatch(
quickTargetInsideBridgeMarkup,
/Сохранённое подключение/,
);
const unrelatedMarkup = renderProvisioningWithAttempt({
controller: {
...controller,
@@ -6385,6 +6553,41 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
assert.match(unrelatedMarkup, /Сохранённое подключение/);
});
test("saved Bridge reconnect stays hidden in a fresh Quick Connect draft", () => {
const state = terminalConnectionRecoveryState();
state.connection_attempt = null;
const bridgeTarget = recommendedConnectionRecoveryObservationTarget(state);
assert.equal(bridgeTarget?.connectionMode, "bridge");
const controller = {
...provisioningController(state),
getConnectionRecoveryObservationTarget: () => bridgeTarget,
};
const quickConnectMarkup = renderProvisioning({
controller,
desiredMode: "quick-connect",
});
assert.equal(
buttonMarkupWithText(quickConnectMarkup, "Переподключиться").length,
0,
);
assert.equal(
buttonMarkupWithText(quickConnectMarkup, "Найти по Bluetooth").length,
1,
);
assert.doesNotMatch(quickConnectMarkup, /Сохранённое подключение/);
const bridgeMarkup = renderProvisioning({
controller,
desiredMode: "bridge",
});
assert.equal(
buttonMarkupWithText(bridgeMarkup, "Переподключиться").length,
1,
);
assert.match(bridgeMarkup, /Сохранённое подключение/);
});
test("read-only reconnect keeps its card and loader across a transient projection", async () => {
const initialState = terminalConnectionRecoveryState();
initialState.connection_attempt = null;